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/unblock-user";
|
||||
import "./user/get-user-friends";
|
||||
import "./user/get-user-stats";
|
||||
import "./user/report-user";
|
||||
import "./profile/get-friend-requests";
|
||||
import "./profile/get-me";
|
||||
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
|
||||
): Promise<UserProfile | null> => {
|
||||
try {
|
||||
const [profile, friends] = await Promise.all([
|
||||
HydraApi.get<UserProfile | null>(`/users/${userId}`),
|
||||
getUserFriends(userId, 12, 0).catch(() => {
|
||||
return { totalFriends: 0, friends: [] };
|
||||
}),
|
||||
]);
|
||||
const profile = await HydraApi.get<UserProfile | null>(`/users/${userId}`);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
|
@ -77,10 +72,9 @@ const getUser = async (
|
|||
...profile,
|
||||
libraryGames,
|
||||
recentGames,
|
||||
friends: friends.friends,
|
||||
totalFriends: friends.totalFriends,
|
||||
};
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
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),
|
||||
getBlockedUsers: (take: number, skip: number) =>
|
||||
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 */
|
||||
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 { useAppDispatch } from "@renderer/hooks";
|
||||
import type { UserPreferences } from "@types";
|
||||
import type { UserBlocks, UserPreferences } from "@types";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export interface SettingsContext {
|
||||
|
@ -11,6 +11,8 @@ export interface SettingsContext {
|
|||
clearSourceUrl: () => void;
|
||||
sourceUrl: string | null;
|
||||
currentCategoryIndex: number;
|
||||
blockedUsers: UserBlocks["blocks"];
|
||||
fetchBlockedUsers: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const settingsContext = createContext<SettingsContext>({
|
||||
|
@ -19,6 +21,8 @@ export const settingsContext = createContext<SettingsContext>({
|
|||
clearSourceUrl: () => {},
|
||||
sourceUrl: null,
|
||||
currentCategoryIndex: 0,
|
||||
blockedUsers: [],
|
||||
fetchBlockedUsers: async () => {},
|
||||
});
|
||||
|
||||
const { Provider } = settingsContext;
|
||||
|
@ -35,6 +39,8 @@ export function SettingsContextProvider({
|
|||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
|
||||
|
||||
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const defaultSourceUrl = searchParams.get("urls");
|
||||
|
||||
|
@ -48,6 +54,15 @@ export function SettingsContextProvider({
|
|||
}
|
||||
}, [defaultSourceUrl]);
|
||||
|
||||
const fetchBlockedUsers = useCallback(async () => {
|
||||
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
|
||||
setBlockedUsers(blockedUsers.blocks);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBlockedUsers();
|
||||
}, [fetchBlockedUsers]);
|
||||
|
||||
const clearSourceUrl = () => setSourceUrl(null);
|
||||
|
||||
const updateUserPreferences = async (values: Partial<UserPreferences>) => {
|
||||
|
@ -63,8 +78,10 @@ export function SettingsContextProvider({
|
|||
updateUserPreferences,
|
||||
setCurrentCategoryIndex,
|
||||
clearSourceUrl,
|
||||
fetchBlockedUsers,
|
||||
currentCategoryIndex,
|
||||
sourceUrl,
|
||||
blockedUsers,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { darkenColor } from "@renderer/helpers";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import type { UserProfile } from "@types";
|
||||
import type { UserProfile, UserStats } from "@types";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
|
@ -12,6 +12,7 @@ export interface UserProfileContext {
|
|||
heroBackground: string;
|
||||
/* Indicates if the current user is viewing their own profile */
|
||||
isMe: boolean;
|
||||
userStats: UserStats | null;
|
||||
|
||||
getUserProfile: () => Promise<void>;
|
||||
}
|
||||
|
@ -22,6 +23,7 @@ export const userProfileContext = createContext<UserProfileContext>({
|
|||
userProfile: null,
|
||||
heroBackground: DEFAULT_USER_PROFILE_BACKGROUND,
|
||||
isMe: false,
|
||||
userStats: null,
|
||||
getUserProfile: async () => {},
|
||||
});
|
||||
|
||||
|
@ -39,6 +41,8 @@ export function UserProfileContextProvider({
|
|||
}: UserProfileContextProviderProps) {
|
||||
const { userDetails } = useAppSelector((state) => state.userDetails);
|
||||
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [heroBackground, setHeroBackground] = useState(
|
||||
DEFAULT_USER_PROFILE_BACKGROUND
|
||||
|
@ -58,7 +62,15 @@ export function UserProfileContextProvider({
|
|||
const { showErrorToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getUserStats = useCallback(async () => {
|
||||
window.electron.getUserStats(userId).then((stats) => {
|
||||
setUserStats(stats);
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
const getUserProfile = useCallback(async () => {
|
||||
getUserStats();
|
||||
|
||||
return window.electron.getUser(userId).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
setUserProfile(userProfile);
|
||||
|
@ -73,7 +85,7 @@ export function UserProfileContextProvider({
|
|||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}, [navigate, showErrorToast, userId, t]);
|
||||
}, [navigate, getUserStats, showErrorToast, userId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserProfile(null);
|
||||
|
@ -89,6 +101,7 @@ export function UserProfileContextProvider({
|
|||
heroBackground,
|
||||
isMe: userDetails?.id === userProfile?.id,
|
||||
getUserProfile,
|
||||
userStats,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
7
src/renderer/src/declaration.d.ts
vendored
7
src/renderer/src/declaration.d.ts
vendored
|
@ -22,6 +22,7 @@ import type {
|
|||
UpdateProfileRequest,
|
||||
GameStats,
|
||||
TrendingGame,
|
||||
UserStats,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
|
@ -143,6 +144,12 @@ declare global {
|
|||
skip: number
|
||||
) => Promise<UserFriends>;
|
||||
getBlockedUsers: (take: number, skip: number) => Promise<UserBlocks>;
|
||||
getUserStats: (userId: string) => Promise<UserStats>;
|
||||
reportUser: (
|
||||
userId: string,
|
||||
reason: string,
|
||||
description: string
|
||||
) => Promise<void>;
|
||||
|
||||
/* Profile */
|
||||
getMe: () => Promise<UserProfile | null>;
|
||||
|
|
|
@ -97,7 +97,7 @@ export const statsSection = style({
|
|||
});
|
||||
|
||||
export const statsCategoryTitle = style({
|
||||
fontSize: "16px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
display: "flex",
|
||||
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",
|
||||
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 { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
|
@ -7,23 +7,26 @@ import { steamUrlBuilder } from "@shared";
|
|||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
import * as styles from "./profile-content.css";
|
||||
import { ClockIcon, TelescopeIcon } from "@primer/octicons-react";
|
||||
import { Link } from "@renderer/components";
|
||||
import { TelescopeIcon } from "@primer/octicons-react";
|
||||
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 { 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() {
|
||||
const { userProfile, isMe } = useContext(userProfileContext);
|
||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setHeaderTitle(""));
|
||||
|
||||
if (userProfile) {
|
||||
dispatch(setHeaderTitle(userProfile.displayName));
|
||||
}
|
||||
|
@ -33,22 +36,9 @@ export function ProfileContent() {
|
|||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formatPlayTime = useCallback(
|
||||
(game: UserGame) => {
|
||||
const seconds = game?.playTimeInSeconds || 0;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("amount_minutes", {
|
||||
amount: minutes.toFixed(0),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
return t("amount_hours", { amount: numberFormatter.format(hours) });
|
||||
},
|
||||
[numberFormatter, t]
|
||||
);
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
|
||||
const buildUserGameDetailsPath = (game: UserGame) =>
|
||||
buildGameDetailsPath({
|
||||
|
@ -56,10 +46,6 @@ export function ProfileContent() {
|
|||
objectID: game.objectId,
|
||||
});
|
||||
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!userProfile) return null;
|
||||
|
||||
|
@ -95,9 +81,9 @@ export function ProfileContent() {
|
|||
<div className={styles.sectionHeader}>
|
||||
<h2>{t("library")}</h2>
|
||||
|
||||
<span>
|
||||
{numberFormatter.format(userProfile.libraryGames.length)}
|
||||
</span>
|
||||
{userStats && (
|
||||
<span>{numberFormatter.format(userStats.libraryCount)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className={styles.gamesGrid}>
|
||||
|
@ -135,112 +121,14 @@ export function ProfileContent() {
|
|||
</div>
|
||||
|
||||
<div className={styles.rightContent}>
|
||||
{userProfile?.recentGames?.length > 0 && (
|
||||
<div>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2>{t("activity")}</h2>
|
||||
</div>
|
||||
<RecentGamesBox />
|
||||
<FriendsBox />
|
||||
|
||||
<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}
|
||||
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>
|
||||
<ReportProfile />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}, [
|
||||
userProfile,
|
||||
formatPlayTime,
|
||||
numberFormatter,
|
||||
t,
|
||||
usersAreFriends,
|
||||
isMe,
|
||||
navigate,
|
||||
]);
|
||||
}, [userProfile, isMe, usersAreFriends, numberFormatter, t, navigate]);
|
||||
|
||||
return (
|
||||
<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") {
|
||||
return (
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
{t("undo_friendship")}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
theme="danger"
|
||||
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<BlockedIcon />
|
||||
{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",
|
||||
height: "32px",
|
||||
borderRadius: "4px",
|
||||
filter: "grayscale(100%)",
|
||||
});
|
||||
|
||||
export const blockedUser = style({
|
||||
|
@ -28,4 +29,19 @@ export const blockedUser = style({
|
|||
export const unblockButton = style({
|
||||
color: vars.color.muted,
|
||||
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 { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { XCircleFillIcon } from "@primer/octicons-react";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
interface FormValues {
|
||||
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
|
||||
|
@ -15,8 +16,12 @@ interface FormValues {
|
|||
export function SettingsPrivacy() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const [isUnblocking, setIsUnblocking] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const { blockedUsers, fetchBlockedUsers } = useContext(settingsContext);
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
|
@ -26,7 +31,7 @@ export function SettingsPrivacy() {
|
|||
|
||||
const { patchUser, userDetails } = useUserDetails();
|
||||
|
||||
const [blockedUsers, setBlockedUsers] = useState<any[]>([]);
|
||||
const { unblockUser } = useUserDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.profileVisibility) {
|
||||
|
@ -34,14 +39,6 @@ export function SettingsPrivacy() {
|
|||
}
|
||||
}, [userDetails, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.getBlockedUsers(12, 0).then((users) => {
|
||||
setBlockedUsers(users.blocks);
|
||||
});
|
||||
}, []);
|
||||
|
||||
console.log("BLOCKED USERS", blockedUsers);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
{ value: "FRIENDS", label: t("friends_only") },
|
||||
|
@ -53,6 +50,25 @@ export function SettingsPrivacy() {
|
|||
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 (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
|
@ -90,14 +106,7 @@ export function SettingsPrivacy() {
|
|||
Usuários bloqueados
|
||||
</h3>
|
||||
|
||||
<ul
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
listStyle: "none",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<ul className={styles.blockedUsersList}>
|
||||
{blockedUsers.map((user) => {
|
||||
return (
|
||||
<li key={user.id} className={styles.blockedUser}>
|
||||
|
@ -116,7 +125,12 @@ export function SettingsPrivacy() {
|
|||
<span>{user.displayName}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" className={styles.unblockButton}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.unblockButton}
|
||||
onClick={() => handleUnblockClick(user.id)}
|
||||
disabled={isUnblocking}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
@ -235,5 +235,10 @@ export interface TrendingGame {
|
|||
logo: string | null;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
libraryCount: number;
|
||||
friendsCount: number;
|
||||
}
|
||||
|
||||
export * from "./steam.types";
|
||||
export * from "./real-debrid.types";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue