mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: adding user report
This commit is contained in:
parent
fcc24d6b94
commit
8799378bf2
20 changed files with 463 additions and 228 deletions
|
@ -1,56 +0,0 @@
|
||||||
// electron.vite.config.ts
|
|
||||||
import { resolve } from "path";
|
|
||||||
import {
|
|
||||||
defineConfig,
|
|
||||||
loadEnv,
|
|
||||||
swcPlugin,
|
|
||||||
externalizeDepsPlugin,
|
|
||||||
} from "electron-vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
|
||||||
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
|
|
||||||
import svgr from "vite-plugin-svgr";
|
|
||||||
var sentryPlugin = sentryVitePlugin({
|
|
||||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
|
||||||
org: "hydra-launcher",
|
|
||||||
project: "hydra-launcher",
|
|
||||||
});
|
|
||||||
var electron_vite_config_default = defineConfig(({ mode }) => {
|
|
||||||
loadEnv(mode);
|
|
||||||
return {
|
|
||||||
main: {
|
|
||||||
build: {
|
|
||||||
sourcemap: true,
|
|
||||||
rollupOptions: {
|
|
||||||
external: ["better-sqlite3"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@main": resolve("src/main"),
|
|
||||||
"@locales": resolve("src/locales"),
|
|
||||||
"@resources": resolve("resources"),
|
|
||||||
"@shared": resolve("src/shared"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
|
|
||||||
},
|
|
||||||
preload: {
|
|
||||||
plugins: [externalizeDepsPlugin()],
|
|
||||||
},
|
|
||||||
renderer: {
|
|
||||||
build: {
|
|
||||||
sourcemap: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@renderer": resolve("src/renderer/src"),
|
|
||||||
"@locales": resolve("src/locales"),
|
|
||||||
"@shared": resolve("src/shared"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
export { electron_vite_config_default as default };
|
|
|
@ -49,6 +49,8 @@ import "./user/get-blocked-users";
|
||||||
import "./user/block-user";
|
import "./user/block-user";
|
||||||
import "./user/unblock-user";
|
import "./user/unblock-user";
|
||||||
import "./user/get-user-friends";
|
import "./user/get-user-friends";
|
||||||
|
import "./user/get-user-stats";
|
||||||
|
import "./user/report-user";
|
||||||
import "./profile/get-friend-requests";
|
import "./profile/get-friend-requests";
|
||||||
import "./profile/get-me";
|
import "./profile/get-me";
|
||||||
import "./profile/undo-friendship";
|
import "./profile/undo-friendship";
|
||||||
|
|
12
src/main/events/user/get-user-stats.ts
Normal file
12
src/main/events/user/get-user-stats.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { HydraApi } from "@main/services";
|
||||||
|
import type { UserStats } from "@types";
|
||||||
|
|
||||||
|
export const getUserStats = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
userId: string
|
||||||
|
): Promise<UserStats> => {
|
||||||
|
return HydraApi.get(`/users/${userId}/stats`);
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("getUserStats", getUserStats);
|
|
@ -27,12 +27,7 @@ const getUser = async (
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<UserProfile | null> => {
|
): Promise<UserProfile | null> => {
|
||||||
try {
|
try {
|
||||||
const [profile, friends] = await Promise.all([
|
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
|
||||||
HydraApi.get<UserProfile | null>(`/users/${userId}`),
|
|
||||||
getUserFriends(userId, 12, 0).catch(() => {
|
|
||||||
return { totalFriends: 0, friends: [] };
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!profile) return null;
|
if (!profile) return null;
|
||||||
|
|
||||||
|
@ -77,10 +72,9 @@ const getUser = async (
|
||||||
...profile,
|
...profile,
|
||||||
libraryGames,
|
libraryGames,
|
||||||
recentGames,
|
recentGames,
|
||||||
friends: friends.friends,
|
|
||||||
totalFriends: friends.totalFriends,
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
16
src/main/events/user/report-user.ts
Normal file
16
src/main/events/user/report-user.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { registerEvent } from "../register-event";
|
||||||
|
import { HydraApi } from "@main/services";
|
||||||
|
|
||||||
|
export const reportUser = async (
|
||||||
|
_event: Electron.IpcMainInvokeEvent,
|
||||||
|
userId: string,
|
||||||
|
reason: string,
|
||||||
|
description: string
|
||||||
|
): Promise<void> => {
|
||||||
|
return HydraApi.post(`/users/${userId}/report`, {
|
||||||
|
reason,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
registerEvent("reportUser", reportUser);
|
|
@ -161,6 +161,9 @@ contextBridge.exposeInMainWorld("electron", {
|
||||||
ipcRenderer.invoke("getUserFriends", userId, take, skip),
|
ipcRenderer.invoke("getUserFriends", userId, take, skip),
|
||||||
getBlockedUsers: (take: number, skip: number) =>
|
getBlockedUsers: (take: number, skip: number) =>
|
||||||
ipcRenderer.invoke("getBlockedUsers", take, skip),
|
ipcRenderer.invoke("getBlockedUsers", take, skip),
|
||||||
|
getUserStats: (userId: string) => ipcRenderer.invoke("getUserStats", userId),
|
||||||
|
reportUser: (userId: string, reason: string, description: string) =>
|
||||||
|
ipcRenderer.invoke("reportUser", userId, reason, description),
|
||||||
|
|
||||||
/* Auth */
|
/* Auth */
|
||||||
signOut: () => ipcRenderer.invoke("signOut"),
|
signOut: () => ipcRenderer.invoke("signOut"),
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { createContext, useEffect, useState } from "react";
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { setUserPreferences } from "@renderer/features";
|
import { setUserPreferences } from "@renderer/features";
|
||||||
import { useAppDispatch } from "@renderer/hooks";
|
import { useAppDispatch } from "@renderer/hooks";
|
||||||
import type { UserPreferences } from "@types";
|
import type { UserBlocks, UserPreferences } from "@types";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
export interface SettingsContext {
|
export interface SettingsContext {
|
||||||
|
@ -11,6 +11,8 @@ export interface SettingsContext {
|
||||||
clearSourceUrl: () => void;
|
clearSourceUrl: () => void;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
currentCategoryIndex: number;
|
currentCategoryIndex: number;
|
||||||
|
blockedUsers: UserBlocks["blocks"];
|
||||||
|
fetchBlockedUsers: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsContext = createContext<SettingsContext>({
|
export const settingsContext = createContext<SettingsContext>({
|
||||||
|
@ -19,6 +21,8 @@ export const settingsContext = createContext<SettingsContext>({
|
||||||
clearSourceUrl: () => {},
|
clearSourceUrl: () => {},
|
||||||
sourceUrl: null,
|
sourceUrl: null,
|
||||||
currentCategoryIndex: 0,
|
currentCategoryIndex: 0,
|
||||||
|
blockedUsers: [],
|
||||||
|
fetchBlockedUsers: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { Provider } = settingsContext;
|
const { Provider } = settingsContext;
|
||||||
|
@ -35,6 +39,8 @@ export function SettingsContextProvider({
|
||||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||||
|
|
||||||
|
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const defaultSourceUrl = searchParams.get("urls");
|
const defaultSourceUrl = searchParams.get("urls");
|
||||||
|
|
||||||
|
@ -48,6 +54,15 @@ export function SettingsContextProvider({
|
||||||
}
|
}
|
||||||
}, [defaultSourceUrl]);
|
}, [defaultSourceUrl]);
|
||||||
|
|
||||||
|
const fetchBlockedUsers = useCallback(async () => {
|
||||||
|
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
|
||||||
|
setBlockedUsers(blockedUsers.blocks);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlockedUsers();
|
||||||
|
}, [fetchBlockedUsers]);
|
||||||
|
|
||||||
const clearSourceUrl = () => setSourceUrl(null);
|
const clearSourceUrl = () => setSourceUrl(null);
|
||||||
|
|
||||||
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
|
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
|
||||||
|
@ -63,8 +78,10 @@ export function SettingsContextProvider({
|
||||||
updateUserPreferences,
|
updateUserPreferences,
|
||||||
setCurrentCategoryIndex,
|
setCurrentCategoryIndex,
|
||||||
clearSourceUrl,
|
clearSourceUrl,
|
||||||
|
fetchBlockedUsers,
|
||||||
currentCategoryIndex,
|
currentCategoryIndex,
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
|
blockedUsers,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { darkenColor } from "@renderer/helpers";
|
import { darkenColor } from "@renderer/helpers";
|
||||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||||
import type { UserProfile } from "@types";
|
import type { UserProfile, UserStats } from "@types";
|
||||||
import { average } from "color.js";
|
import { average } from "color.js";
|
||||||
|
|
||||||
import { createContext, useCallback, useEffect, useState } from "react";
|
import { createContext, useCallback, useEffect, useState } from "react";
|
||||||
|
@ -12,6 +12,7 @@ export interface UserProfileContext {
|
||||||
heroBackground: string;
|
heroBackground: string;
|
||||||
/* Indicates if the current user is viewing their own profile */
|
/* Indicates if the current user is viewing their own profile */
|
||||||
isMe: boolean;
|
isMe: boolean;
|
||||||
|
userStats: UserStats | null;
|
||||||
|
|
||||||
getUserProfile: () => Promise<void>;
|
getUserProfile: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -22,6 +23,7 @@ export const userProfileContext = createContext<UserProfileContext>({
|
||||||
userProfile: null,
|
userProfile: null,
|
||||||
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
|
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
|
||||||
isMe: false,
|
isMe: false,
|
||||||
|
userStats: null,
|
||||||
getUserProfile: async () => {},
|
getUserProfile: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -39,6 +41,8 @@ export function UserProfileContextProvider({
|
||||||
}: UserProfileContextProviderProps) {
|
}: UserProfileContextProviderProps) {
|
||||||
const { userDetails } = useAppSelector((state) => state.userDetails);
|
const { userDetails } = useAppSelector((state) => state.userDetails);
|
||||||
|
|
||||||
|
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||||
|
|
||||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||||
const [heroBackground, setHeroBackground] = useState(
|
const [heroBackground, setHeroBackground] = useState(
|
||||||
DEFAULT_USER_PROFILE_BACKGROUND
|
DEFAULT_USER_PROFILE_BACKGROUND
|
||||||
|
@ -58,7 +62,15 @@ export function UserProfileContextProvider({
|
||||||
const { showErrorToast } = useToast();
|
const { showErrorToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const getUserStats = useCallback(async () => {
|
||||||
|
window.electron.getUserStats(userId).then((stats) => {
|
||||||
|
setUserStats(stats);
|
||||||
|
});
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
const getUserProfile = useCallback(async () => {
|
const getUserProfile = useCallback(async () => {
|
||||||
|
getUserStats();
|
||||||
|
|
||||||
return window.electron.getUser(userId).then((userProfile) => {
|
return window.electron.getUser(userId).then((userProfile) => {
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
setUserProfile(userProfile);
|
setUserProfile(userProfile);
|
||||||
|
@ -73,7 +85,7 @@ export function UserProfileContextProvider({
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [navigate, showErrorToast, userId, t]);
|
}, [navigate, getUserStats, showErrorToast, userId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
|
@ -89,6 +101,7 @@ export function UserProfileContextProvider({
|
||||||
heroBackground,
|
heroBackground,
|
||||||
isMe: userDetails?.id === userProfile?.id,
|
isMe: userDetails?.id === userProfile?.id,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
|
userStats,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
7
src/renderer/src/declaration.d.ts
vendored
7
src/renderer/src/declaration.d.ts
vendored
|
@ -22,6 +22,7 @@ import type {
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
GameStats,
|
GameStats,
|
||||||
TrendingGame,
|
TrendingGame,
|
||||||
|
UserStats,
|
||||||
} from "@types";
|
} from "@types";
|
||||||
import type { DiskSpace } from "check-disk-space";
|
import type { DiskSpace } from "check-disk-space";
|
||||||
|
|
||||||
|
@ -143,6 +144,12 @@ declare global {
|
||||||
skip: number
|
skip: number
|
||||||
) => Promise<UserFriends>;
|
) => Promise<UserFriends>;
|
||||||
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
|
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
|
||||||
|
getUserStats: (userId: string) => Promise<UserStats>;
|
||||||
|
reportUser: (
|
||||||
|
userId: string,
|
||||||
|
reason: string,
|
||||||
|
description: string
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
/* Profile */
|
/* Profile */
|
||||||
getMe: () => Promise<UserProfile | null>;
|
getMe: () => Promise<UserProfile | null>;
|
||||||
|
|
|
@ -97,7 +97,7 @@ export const statsSection = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statsCategoryTitle = style({
|
export const statsCategoryTitle = style({
|
||||||
fontSize: "16px",
|
fontSize: "14px",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { userProfileContext } from "@renderer/context";
|
||||||
|
import { useFormat } from "@renderer/hooks";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import * as styles from "./profile-content.css";
|
||||||
|
import { Link } from "@renderer/components";
|
||||||
|
|
||||||
|
export function FriendsBox() {
|
||||||
|
const { userProfile, userStats } = useContext(userProfileContext);
|
||||||
|
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
|
||||||
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h2>{t("friends")}</h2>
|
||||||
|
{userStats && (
|
||||||
|
<span>{numberFormatter.format(userStats.friendsCount)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.box}>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{userProfile?.friends.map((friend) => (
|
||||||
|
<li key={friend.id}>
|
||||||
|
<Link to={`/profile/${friend.id}`} className={styles.listItem}>
|
||||||
|
<img
|
||||||
|
src={friend.profileImageUrl!}
|
||||||
|
alt={friend.displayName}
|
||||||
|
className={styles.listItemImage}
|
||||||
|
/>
|
||||||
|
<span className={styles.friendName}>{friend.displayName}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -150,3 +150,29 @@ export const noGames = style({
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: `${SPACING_UNIT}px`,
|
gap: `${SPACING_UNIT}px`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listItemImage = style({
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemDetails = style({
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: `${SPACING_UNIT / 2}px`,
|
||||||
|
overflow: "hidden",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemTitle = style({
|
||||||
|
fontWeight: "bold",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listItemDescription = style({
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
});
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { userProfileContext } from "@renderer/context";
|
import { userProfileContext } from "@renderer/context";
|
||||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
import { useContext, useEffect, useMemo } from "react";
|
||||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||||
import { setHeaderTitle } from "@renderer/features";
|
import { setHeaderTitle } from "@renderer/features";
|
||||||
|
@ -7,23 +7,26 @@ import { steamUrlBuilder } from "@shared";
|
||||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||||
|
|
||||||
import * as styles from "./profile-content.css";
|
import * as styles from "./profile-content.css";
|
||||||
import { ClockIcon, TelescopeIcon } from "@primer/octicons-react";
|
import { TelescopeIcon } from "@primer/octicons-react";
|
||||||
import { Link } from "@renderer/components";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { UserGame } from "@types";
|
|
||||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
|
||||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { LockedProfile } from "./locked-profile";
|
import { LockedProfile } from "./locked-profile";
|
||||||
|
import { ReportProfile } from "../report-profile/report-profile";
|
||||||
|
import { FriendsBox } from "./friends-box";
|
||||||
|
import { RecentGamesBox } from "./recent-games-box";
|
||||||
|
import { UserGame } from "@types";
|
||||||
|
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||||
|
|
||||||
export function ProfileContent() {
|
export function ProfileContent() {
|
||||||
const { userProfile, isMe } = useContext(userProfileContext);
|
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const { t } = useTranslation("user_profile");
|
const { t } = useTranslation("user_profile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
dispatch(setHeaderTitle(""));
|
||||||
|
|
||||||
if (userProfile) {
|
if (userProfile) {
|
||||||
dispatch(setHeaderTitle(userProfile.displayName));
|
dispatch(setHeaderTitle(userProfile.displayName));
|
||||||
}
|
}
|
||||||
|
@ -33,22 +36,9 @@ export function ProfileContent() {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const formatPlayTime = useCallback(
|
const usersAreFriends = useMemo(() => {
|
||||||
(game: UserGame) => {
|
return userProfile?.relation?.status === "ACCEPTED";
|
||||||
const seconds = game?.playTimeInSeconds || 0;
|
}, [userProfile]);
|
||||||
const minutes = seconds / 60;
|
|
||||||
|
|
||||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
|
||||||
return t("amount_minutes", {
|
|
||||||
amount: minutes.toFixed(0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hours = minutes / 60;
|
|
||||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
|
||||||
},
|
|
||||||
[numberFormatter, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildUserGameDetailsPath = (game: UserGame) =>
|
const buildUserGameDetailsPath = (game: UserGame) =>
|
||||||
buildGameDetailsPath({
|
buildGameDetailsPath({
|
||||||
|
@ -56,10 +46,6 @@ export function ProfileContent() {
|
||||||
objectID: game.objectId,
|
objectID: game.objectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const usersAreFriends = useMemo(() => {
|
|
||||||
return userProfile?.relation?.status === "ACCEPTED";
|
|
||||||
}, [userProfile]);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (!userProfile) return null;
|
if (!userProfile) return null;
|
||||||
|
|
||||||
|
@ -95,9 +81,9 @@ export function ProfileContent() {
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
<h2>{t("library")}</h2>
|
<h2>{t("library")}</h2>
|
||||||
|
|
||||||
<span>
|
{userStats && (
|
||||||
{numberFormatter.format(userProfile.libraryGames.length)}
|
<span>{numberFormatter.format(userStats.libraryCount)}</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className={styles.gamesGrid}>
|
<ul className={styles.gamesGrid}>
|
||||||
|
@ -135,112 +121,14 @@ export function ProfileContent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.rightContent}>
|
<div className={styles.rightContent}>
|
||||||
{userProfile?.recentGames?.length > 0 && (
|
<RecentGamesBox />
|
||||||
<div>
|
<FriendsBox />
|
||||||
<div className={styles.sectionHeader}>
|
|
||||||
<h2>{t("activity")}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.box}>
|
<ReportProfile />
|
||||||
<ul className={styles.list}>
|
|
||||||
{userProfile?.recentGames.map((game) => (
|
|
||||||
<li key={`${game.shop}-${game.objectId}`}>
|
|
||||||
<Link
|
|
||||||
to={buildUserGameDetailsPath(game)}
|
|
||||||
className={styles.listItem}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={game.iconUrl!}
|
|
||||||
alt={game.title}
|
|
||||||
style={{
|
|
||||||
width: "32px",
|
|
||||||
height: "32px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: `${SPACING_UNIT / 2}px`,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontWeight: "bold",
|
|
||||||
overflow: "hidden",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{game.title}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: `${SPACING_UNIT}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClockIcon />
|
|
||||||
<small>{formatPlayTime(game)}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className={styles.sectionHeader}>
|
|
||||||
<h2>{t("friends")}</h2>
|
|
||||||
<span>{numberFormatter.format(userProfile?.totalFriends)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.box}>
|
|
||||||
<ul className={styles.list}>
|
|
||||||
{userProfile?.friends.map((friend) => (
|
|
||||||
<li key={friend.id}>
|
|
||||||
<Link
|
|
||||||
to={`/profile/${friend.id}`}
|
|
||||||
className={styles.listItem}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={friend.profileImageUrl!}
|
|
||||||
alt={friend.displayName}
|
|
||||||
style={{
|
|
||||||
width: "32px",
|
|
||||||
height: "32px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className={styles.friendName}>
|
|
||||||
{friend.displayName}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}, [
|
}, [userProfile, isMe, usersAreFriends, numberFormatter, t, navigate]);
|
||||||
userProfile,
|
|
||||||
formatPlayTime,
|
|
||||||
numberFormatter,
|
|
||||||
t,
|
|
||||||
usersAreFriends,
|
|
||||||
isMe,
|
|
||||||
navigate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
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";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function RecentGamesBox() {
|
||||||
|
const { userProfile } = useContext(userProfileContext);
|
||||||
|
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
|
||||||
|
const { numberFormatter } = useFormat();
|
||||||
|
|
||||||
|
const formatPlayTime = useCallback(
|
||||||
|
(game: UserGame) => {
|
||||||
|
const seconds = game?.playTimeInSeconds || 0;
|
||||||
|
const minutes = seconds / 60;
|
||||||
|
|
||||||
|
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||||
|
return t("amount_minutes", {
|
||||||
|
amount: minutes.toFixed(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = minutes / 60;
|
||||||
|
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||||
|
},
|
||||||
|
[numberFormatter, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildUserGameDetailsPath = (game: UserGame) =>
|
||||||
|
buildGameDetailsPath({
|
||||||
|
...game,
|
||||||
|
objectID: game.objectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userProfile?.recentGames.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.sectionHeader}>
|
||||||
|
<h2>{t("activity")}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.box}>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
{userProfile?.recentGames.map((game) => (
|
||||||
|
<li key={`${game.shop}-${game.objectId}`}>
|
||||||
|
<Link
|
||||||
|
to={buildUserGameDetailsPath(game)}
|
||||||
|
className={styles.listItem}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={game.iconUrl!}
|
||||||
|
alt={game.title}
|
||||||
|
className={styles.listItemImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.listItemDetails}>
|
||||||
|
<span className={styles.listItemTitle}>{game.title}</span>
|
||||||
|
|
||||||
|
<div className={styles.listItemDescription}>
|
||||||
|
<ClockIcon />
|
||||||
|
<small>{formatPlayTime(game)}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -167,14 +167,26 @@ export function ProfileHero() {
|
||||||
|
|
||||||
if (userProfile.relation.status === "ACCEPTED") {
|
if (userProfile.relation.status === "ACCEPTED") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
theme="outline"
|
<Button
|
||||||
onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")}
|
theme="danger"
|
||||||
disabled={isPerformingAction}
|
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
|
||||||
>
|
disabled={isPerformingAction}
|
||||||
<XCircleFillIcon />
|
>
|
||||||
{t("undo_friendship")}
|
<BlockedIcon />
|
||||||
</Button>
|
{t("block_user")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="outline"
|
||||||
|
onClick={() =>
|
||||||
|
handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")
|
||||||
|
}
|
||||||
|
disabled={isPerformingAction}
|
||||||
|
>
|
||||||
|
<XCircleFillIcon />
|
||||||
|
{t("undo_friendship")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||||
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
export const reportButton = style({
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
color: vars.color.muted,
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
display: "flex",
|
||||||
|
cursor: "pointer",
|
||||||
|
alignItems: "center",
|
||||||
|
fontSize: "12px",
|
||||||
|
});
|
131
src/renderer/src/pages/profile/report-profile/report-profile.tsx
Normal file
131
src/renderer/src/pages/profile/report-profile/report-profile.tsx
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
const reportReasons = ["hate", "sexual_content", "violence", "spam", "other"];
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
reason: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportProfile() {
|
||||||
|
const [showReportProfileModal, setShowReportProfileModal] = useState(false);
|
||||||
|
|
||||||
|
const { userProfile, isMe } = useContext(userProfileContext);
|
||||||
|
|
||||||
|
const { t } = useTranslation("user_profile");
|
||||||
|
|
||||||
|
const schema = yup.object().shape({
|
||||||
|
reason: yup.string().required(t("required_field")),
|
||||||
|
description: yup.string().required(t("required_field")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting, errors },
|
||||||
|
reset,
|
||||||
|
handleSubmit,
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
reason: "hate",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset({
|
||||||
|
reason: "hate",
|
||||||
|
description: "",
|
||||||
|
});
|
||||||
|
}, [userProfile, reset]);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (values: FormValues) => {
|
||||||
|
return window.electron
|
||||||
|
.reportUser(userProfile!.id, values.reason, values.description)
|
||||||
|
.then(() => {
|
||||||
|
showSuccessToast(t("profile_reported"));
|
||||||
|
setShowReportProfileModal(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[userProfile, showSuccessToast, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMe) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
visible={showReportProfileModal}
|
||||||
|
onClose={() => setShowReportProfileModal(false)}
|
||||||
|
title="Report this profile"
|
||||||
|
clickOutsideToClose={false}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: `${SPACING_UNIT * 2}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="reason"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<SelectField
|
||||||
|
label={t("report_reason")}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
options={reportReasons.map((reason) => ({
|
||||||
|
key: reason,
|
||||||
|
value: reason,
|
||||||
|
label: t(`report_reason_${reason}`),
|
||||||
|
}))}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
{...register("description")}
|
||||||
|
label={t("report_description")}
|
||||||
|
placeholder={t("report_description_placeholder")}
|
||||||
|
error={errors.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
style={{ marginTop: `${SPACING_UNIT}px`, alignSelf: "flex-end" }}
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
{t("report")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.reportButton}
|
||||||
|
onClick={() => setShowReportProfileModal(true)}
|
||||||
|
>
|
||||||
|
<ReportIcon size={13} />
|
||||||
|
{t("report_profile")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ export const blockedUserAvatar = style({
|
||||||
width: "32px",
|
width: "32px",
|
||||||
height: "32px",
|
height: "32px",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
|
filter: "grayscale(100%)",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const blockedUser = style({
|
export const blockedUser = style({
|
||||||
|
@ -28,4 +29,19 @@ export const blockedUser = style({
|
||||||
export const unblockButton = style({
|
export const unblockButton = style({
|
||||||
color: vars.color.muted,
|
color: vars.color.muted,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
|
transition: "all ease 0.2s",
|
||||||
|
":hover": {
|
||||||
|
opacity: "0.7",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const blockedUsersList = style({
|
||||||
|
padding: "0",
|
||||||
|
margin: "0",
|
||||||
|
listStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: `${SPACING_UNIT}px`,
|
||||||
|
marginTop: `${SPACING_UNIT}px`,
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,8 +5,9 @@ import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import * as styles from "./settings-privacy.css";
|
import * as styles from "./settings-privacy.css";
|
||||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useContext, useEffect, useState } from "react";
|
||||||
import { XCircleFillIcon } from "@primer/octicons-react";
|
import { XCircleFillIcon } from "@primer/octicons-react";
|
||||||
|
import { settingsContext } from "@renderer/context";
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
|
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
|
||||||
|
@ -15,8 +16,12 @@ interface FormValues {
|
||||||
export function SettingsPrivacy() {
|
export function SettingsPrivacy() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
|
const [isUnblocking, setIsUnblocking] = useState(false);
|
||||||
|
|
||||||
const { showSuccessToast } = useToast();
|
const { showSuccessToast } = useToast();
|
||||||
|
|
||||||
|
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
|
@ -26,7 +31,7 @@ export function SettingsPrivacy() {
|
||||||
|
|
||||||
const { patchUser, userDetails } = useUserDetails();
|
const { patchUser, userDetails } = useUserDetails();
|
||||||
|
|
||||||
const [blockedUsers, setBlockedUsers] = useState<any[]>([]);
|
const { unblockUser } = useUserDetails();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userDetails?.profileVisibility) {
|
if (userDetails?.profileVisibility) {
|
||||||
|
@ -34,14 +39,6 @@ export function SettingsPrivacy() {
|
||||||
}
|
}
|
||||||
}, [userDetails, setValue]);
|
}, [userDetails, setValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.electron.getBlockedUsers(12, 0).then((users) => {
|
|
||||||
setBlockedUsers(users.blocks);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
console.log("BLOCKED USERS", blockedUsers);
|
|
||||||
|
|
||||||
const visibilityOptions = [
|
const visibilityOptions = [
|
||||||
{ value: "PUBLIC", label: t("public") },
|
{ value: "PUBLIC", label: t("public") },
|
||||||
{ value: "FRIENDS", label: t("friends_only") },
|
{ value: "FRIENDS", label: t("friends_only") },
|
||||||
|
@ -53,6 +50,25 @@ export function SettingsPrivacy() {
|
||||||
showSuccessToast(t("changes_saved"));
|
showSuccessToast(t("changes_saved"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnblockClick = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setIsUnblocking(true);
|
||||||
|
|
||||||
|
unblockUser(id)
|
||||||
|
.then(() => {
|
||||||
|
fetchBlockedUsers();
|
||||||
|
// show toast
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
//show toast
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsUnblocking(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[unblockUser, fetchBlockedUsers]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Controller
|
<Controller
|
||||||
|
@ -90,14 +106,7 @@ export function SettingsPrivacy() {
|
||||||
Usuários bloqueados
|
Usuários bloqueados
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<ul
|
<ul className={styles.blockedUsersList}>
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
margin: 0,
|
|
||||||
listStyle: "none",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{blockedUsers.map((user) => {
|
{blockedUsers.map((user) => {
|
||||||
return (
|
return (
|
||||||
<li key={user.id} className={styles.blockedUser}>
|
<li key={user.id} className={styles.blockedUser}>
|
||||||
|
@ -116,7 +125,12 @@ export function SettingsPrivacy() {
|
||||||
<span>{user.displayName}</span>
|
<span>{user.displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className={styles.unblockButton}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.unblockButton}
|
||||||
|
onClick={() => handleUnblockClick(user.id)}
|
||||||
|
disabled={isUnblocking}
|
||||||
|
>
|
||||||
<XCircleFillIcon />
|
<XCircleFillIcon />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -235,5 +235,10 @@ export interface TrendingGame {
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
libraryCount: number;
|
||||||
|
friendsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export * from "./steam.types";
|
export * from "./steam.types";
|
||||||
export * from "./real-debrid.types";
|
export * from "./real-debrid.types";
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue