mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: moving visibility update to settings
This commit is contained in:
parent
383578bca2
commit
2b2b5afd79
51 changed files with 1096 additions and 10511 deletions
|
@ -58,11 +58,11 @@ export const button = styleVariants({
|
|||
danger: [
|
||||
base,
|
||||
{
|
||||
border: `solid 1px #a31533`,
|
||||
backgroundColor: "transparent",
|
||||
color: "white",
|
||||
borderColor: "transparent",
|
||||
backgroundColor: "#a31533",
|
||||
color: "#c0c1c7",
|
||||
":hover": {
|
||||
backgroundColor: "#a31533",
|
||||
backgroundColor: "#b3203f",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
@ -3,26 +3,44 @@ import { Modal, type ModalProps } from "../modal/modal";
|
|||
|
||||
import * as styles from "./confirmation-modal.css";
|
||||
|
||||
export interface ConfirmationModalProps extends ModalProps {
|
||||
export interface ConfirmationModalProps extends Omit<ModalProps, "children"> {
|
||||
confirmButtonLabel: string;
|
||||
cancelButtonLabel: string;
|
||||
descriptionText: string;
|
||||
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmationModal({
|
||||
confirmButtonLabel,
|
||||
cancelButtonLabel,
|
||||
descriptionText,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
...props
|
||||
}: ConfirmationModalProps) {
|
||||
const handleCancelClick = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal {...props}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<p className={styles.descriptionText}>{descriptionText}</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button theme="danger">{cancelButtonLabel}</Button>
|
||||
<Button>{confirmButtonLabel}</Button>
|
||||
<Button theme="outline" onClick={handleCancelClick}>
|
||||
{cancelButtonLabel}
|
||||
</Button>
|
||||
<Button theme="danger" onClick={onConfirm}>
|
||||
{confirmButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -6,7 +6,8 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
|||
import * as styles from "./game-card.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
|
||||
export interface GameCardProps
|
||||
extends React.DetailedHTMLProps<
|
||||
|
@ -25,8 +26,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const uniqueRepackers = Array.from(
|
||||
new Set(game.repacks.map(({ repacker }) => repacker))
|
||||
);
|
||||
|
@ -39,11 +38,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
|||
}
|
||||
}, [game, stats]);
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}, [i18n.language]);
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
|
@ -25,6 +25,9 @@ export const sidebar = recipe({
|
|||
true: {
|
||||
paddingTop: `${SPACING_UNIT * 6}px`,
|
||||
},
|
||||
false: {
|
||||
paddingTop: `${SPACING_UNIT * 2}px`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -149,95 +149,92 @@ export function Sidebar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={styles.sidebar({
|
||||
resizing: isResizing,
|
||||
darwin: window.electron.platform === "darwin",
|
||||
})}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
minWidth: sidebarWidth,
|
||||
maxWidth: sidebarWidth,
|
||||
}}
|
||||
>
|
||||
<SidebarProfile />
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={styles.sidebar({
|
||||
resizing: isResizing,
|
||||
darwin: window.electron.platform === "darwin",
|
||||
})}
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
minWidth: sidebarWidth,
|
||||
maxWidth: sidebarWidth,
|
||||
}}
|
||||
>
|
||||
<SidebarProfile />
|
||||
|
||||
<div className={styles.content}>
|
||||
<section className={styles.section}>
|
||||
<ul className={styles.menu}>
|
||||
{routes.map(({ nameKey, path, render }) => (
|
||||
<li
|
||||
key={nameKey}
|
||||
className={styles.menuItem({
|
||||
active: location.pathname === path,
|
||||
})}
|
||||
<div className={styles.content}>
|
||||
<section className={styles.section}>
|
||||
<ul className={styles.menu}>
|
||||
{routes.map(({ nameKey, path, render }) => (
|
||||
<li
|
||||
key={nameKey}
|
||||
className={styles.menuItem({
|
||||
active: location.pathname === path,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
>
|
||||
{render(isDownloading)}
|
||||
<span>{t(nameKey)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
{render(isDownloading)}
|
||||
<span>{t(nameKey)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
||||
<section className={styles.section}>
|
||||
<small className={styles.sectionTitle}>{t("my_library")}</small>
|
||||
|
||||
<TextField
|
||||
placeholder={t("filter")}
|
||||
onChange={handleFilter}
|
||||
theme="dark"
|
||||
/>
|
||||
<TextField
|
||||
placeholder={t("filter")}
|
||||
onChange={handleFilter}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
<ul className={styles.menu}>
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.menuItem({
|
||||
active:
|
||||
location.pathname ===
|
||||
`/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === "removed",
|
||||
})}
|
||||
<ul className={styles.menu}>
|
||||
{filteredLibrary.map((game) => (
|
||||
<li
|
||||
key={game.id}
|
||||
className={styles.menuItem({
|
||||
active:
|
||||
location.pathname === `/game/${game.shop}/${game.objectID}`,
|
||||
muted: game.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuItemButton}
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
>
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.gameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.gameIcon} />
|
||||
)}
|
||||
{game.iconUrl ? (
|
||||
<img
|
||||
className={styles.gameIcon}
|
||||
src={game.iconUrl}
|
||||
alt={game.title}
|
||||
/>
|
||||
) : (
|
||||
<SteamLogo className={styles.gameIcon} />
|
||||
)}
|
||||
|
||||
<span className={styles.menuItemButtonLabel}>
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<span className={styles.menuItemButtonLabel}>
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.handle}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.handle}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,16 +22,6 @@ export const textField = recipe({
|
|||
minHeight: "40px",
|
||||
},
|
||||
variants: {
|
||||
focused: {
|
||||
true: {
|
||||
borderColor: "#DADBE1",
|
||||
},
|
||||
false: {
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
primary: {
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
|
@ -40,11 +30,21 @@ export const textField = recipe({
|
|||
backgroundColor: vars.color.background,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
error: {
|
||||
hasError: {
|
||||
true: {
|
||||
borderColor: vars.color.danger,
|
||||
},
|
||||
},
|
||||
focused: {
|
||||
true: {
|
||||
borderColor: "#DADBE1",
|
||||
},
|
||||
false: {
|
||||
":hover": {
|
||||
borderColor: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -83,3 +83,7 @@ export const textFieldWrapper = style({
|
|||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const errorLabel = style({
|
||||
color: vars.color.danger,
|
||||
});
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useId, useMemo, useState } from "react";
|
||||
import type { RecipeVariants } from "@vanilla-extract/recipes";
|
||||
import * as styles from "./text-field.css";
|
||||
import type { FieldError, FieldErrorsImpl, Merge } from "react-hook-form";
|
||||
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./text-field.css";
|
||||
|
||||
export interface TextFieldProps
|
||||
extends React.DetailedHTMLProps<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
|
@ -21,71 +23,105 @@ export interface TextFieldProps
|
|||
HTMLDivElement
|
||||
>;
|
||||
rightContent?: React.ReactNode | null;
|
||||
state?: NonNullable<RecipeVariants<typeof styles.textField>>["state"];
|
||||
error?: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
theme = "primary",
|
||||
label,
|
||||
hint,
|
||||
textFieldProps,
|
||||
containerProps,
|
||||
rightContent = null,
|
||||
state,
|
||||
...props
|
||||
}: TextFieldProps) {
|
||||
const id = useId();
|
||||
export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
(
|
||||
{
|
||||
theme = "primary",
|
||||
label,
|
||||
hint,
|
||||
textFieldProps,
|
||||
containerProps,
|
||||
rightContent = null,
|
||||
error,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const id = useId();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
|
||||
const { t } = useTranslation("forms");
|
||||
const { t } = useTranslation("forms");
|
||||
|
||||
const showPasswordToggleButton = props.type === "password";
|
||||
const showPasswordToggleButton = props.type === "password";
|
||||
|
||||
const inputType = useMemo(() => {
|
||||
if (props.type === "password" && isPasswordVisible) return "text";
|
||||
return props.type ?? "text";
|
||||
}, [props.type, isPasswordVisible]);
|
||||
const inputType = useMemo(() => {
|
||||
if (props.type === "password" && isPasswordVisible) return "text";
|
||||
return props.type ?? "text";
|
||||
}, [props.type, isPasswordVisible]);
|
||||
|
||||
return (
|
||||
<div className={styles.textFieldContainer} {...containerProps}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
const hintContent = useMemo(() => {
|
||||
if (error && error.message)
|
||||
return (
|
||||
<small className={styles.errorLabel}>{error.message as string}</small>
|
||||
);
|
||||
|
||||
<div className={styles.textFieldWrapper}>
|
||||
<div
|
||||
className={styles.textField({ focused: isFocused, theme, state })}
|
||||
{...textFieldProps}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
className={styles.textFieldInput({ readOnly: props.readOnly })}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
type={inputType}
|
||||
/>
|
||||
if (hint) return <small>{hint}</small>;
|
||||
return null;
|
||||
}, [hint, error]);
|
||||
|
||||
{showPasswordToggleButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePasswordButton}
|
||||
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||
aria-label={t("toggle_password_visibility")}
|
||||
>
|
||||
{isPasswordVisible ? (
|
||||
<EyeClosedIcon size={16} />
|
||||
) : (
|
||||
<EyeIcon size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => {
|
||||
setIsFocused(true);
|
||||
if (props.onFocus) props.onFocus(event);
|
||||
};
|
||||
|
||||
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => {
|
||||
setIsFocused(false);
|
||||
if (props.onBlur) props.onBlur(event);
|
||||
};
|
||||
|
||||
const hasError = !!error;
|
||||
|
||||
return (
|
||||
<div className={styles.textFieldContainer} {...containerProps}>
|
||||
{label && <label htmlFor={id}>{label}</label>}
|
||||
|
||||
<div className={styles.textFieldWrapper}>
|
||||
<div
|
||||
className={styles.textField({
|
||||
theme,
|
||||
hasError,
|
||||
focused: isFocused,
|
||||
})}
|
||||
{...textFieldProps}
|
||||
>
|
||||
<input
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={styles.textFieldInput({ readOnly: props.readOnly })}
|
||||
{...props}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
type={inputType}
|
||||
/>
|
||||
|
||||
{showPasswordToggleButton && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.togglePasswordButton}
|
||||
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
||||
aria-label={t("toggle_password_visibility")}
|
||||
>
|
||||
{isPasswordVisible ? (
|
||||
<EyeClosedIcon size={16} />
|
||||
) : (
|
||||
<EyeIcon size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rightContent}
|
||||
</div>
|
||||
|
||||
{rightContent}
|
||||
{hintContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
{hint && <small>{hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
TextField.displayName = "TextField";
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
|
@ -15,6 +21,7 @@ import type {
|
|||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
|
||||
export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
game: null,
|
||||
|
@ -29,11 +36,13 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
|||
showRepacksModal: false,
|
||||
showGameOptionsModal: false,
|
||||
stats: null,
|
||||
hasNSFWContentBlocked: false,
|
||||
setGameColor: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
updateGame: async () => {},
|
||||
setShowGameOptionsModal: () => {},
|
||||
setShowRepacksModal: () => {},
|
||||
setHasNSFWContentBlocked: () => {},
|
||||
});
|
||||
|
||||
const { Provider } = gameDetailsContext;
|
||||
|
@ -48,9 +57,10 @@ export function GameDetailsContextProvider({
|
|||
}: GameDetailsContextProps) {
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null);
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
|
@ -97,8 +107,17 @@ export function GameDetailsContextProvider({
|
|||
window.electron.getGameStats(objectID!, shop as GameShop),
|
||||
])
|
||||
.then(([appDetailsResult, repacksResult, statsResult]) => {
|
||||
if (appDetailsResult.status === "fulfilled")
|
||||
setGameDetails(appDetailsResult.value);
|
||||
if (appDetailsResult.status === "fulfilled") {
|
||||
setShopDetails(appDetailsResult.value);
|
||||
|
||||
if (
|
||||
appDetailsResult.value!.content_descriptors.ids.includes(
|
||||
SteamContentDescriptor.AdultOnlySexualContent
|
||||
)
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (repacksResult.status === "fulfilled")
|
||||
setRepacks(repacksResult.value);
|
||||
|
@ -113,7 +132,7 @@ export function GameDetailsContextProvider({
|
|||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
setGameDetails(null);
|
||||
setShopDetails(null);
|
||||
setGame(null);
|
||||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
|
@ -180,6 +199,8 @@ export function GameDetailsContextProvider({
|
|||
showGameOptionsModal,
|
||||
showRepacksModal,
|
||||
stats,
|
||||
hasNSFWContentBlocked,
|
||||
setHasNSFWContentBlocked,
|
||||
setGameColor,
|
||||
selectGameExecutable,
|
||||
updateGame,
|
||||
|
|
|
@ -19,9 +19,11 @@ export interface GameDetailsContext {
|
|||
showRepacksModal: boolean;
|
||||
showGameOptionsModal: boolean;
|
||||
stats: GameStats | null;
|
||||
hasNSFWContentBlocked: boolean;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
updateGame: () => Promise<void>;
|
||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from "./use-date";
|
|||
export * from "./use-toast";
|
||||
export * from "./redux";
|
||||
export * from "./use-user-details";
|
||||
export * from "./use-format";
|
||||
|
|
14
src/renderer/src/hooks/use-format.ts
Normal file
14
src/renderer/src/hooks/use-format.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useFormat() {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
return { numberFormatter };
|
||||
}
|
|
@ -88,7 +88,7 @@ export function useUserDetails() {
|
|||
[updateUserDetails]
|
||||
);
|
||||
|
||||
const fetchFriendRequests = useCallback(() => {
|
||||
const fetchFriendRequests = useCallback(async () => {
|
||||
return window.electron
|
||||
.getFriendRequests()
|
||||
.then((friendRequests) => {
|
||||
|
@ -127,13 +127,10 @@ export function useUserDetails() {
|
|||
[fetchFriendRequests]
|
||||
);
|
||||
|
||||
const undoFriendship = (userId: string) => {
|
||||
return window.electron.undoFriendship(userId);
|
||||
};
|
||||
const undoFriendship = (userId: string) =>
|
||||
window.electron.undoFriendship(userId);
|
||||
|
||||
const blockUser = (userId: string) => {
|
||||
return window.electron.blockUser(userId);
|
||||
};
|
||||
const blockUser = (userId: string) => window.electron.blockUser(userId);
|
||||
|
||||
const unblockUser = (userId: string) => {
|
||||
return window.electron.unblockUser(userId);
|
||||
|
|
|
@ -21,8 +21,14 @@ export function GameDetailsContent() {
|
|||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { objectID, shopDetails, game, gameColor, setGameColor } =
|
||||
useContext(gameDetailsContext);
|
||||
const {
|
||||
objectID,
|
||||
shopDetails,
|
||||
game,
|
||||
gameColor,
|
||||
setGameColor,
|
||||
hasNSFWContentBlocked,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
||||
|
||||
|
@ -64,7 +70,7 @@ export function GameDetailsContent() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
|
||||
<img
|
||||
src={steamUrlBuilder.libraryHero(objectID!)}
|
||||
className={styles.heroImage}
|
||||
|
@ -93,7 +99,7 @@ export function GameDetailsContent() {
|
|||
<div className={styles.heroContent}>
|
||||
<img
|
||||
src={steamUrlBuilder.logo(objectID!)}
|
||||
style={{ width: 300, alignSelf: "flex-end" }}
|
||||
className={styles.gameLogo}
|
||||
alt={game?.title}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { globalStyle, keyframes, style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { recipe } from "@vanilla-extract/recipes";
|
||||
|
||||
export const HERO_HEIGHT = 300;
|
||||
|
||||
|
@ -9,12 +10,22 @@ export const slideIn = keyframes({
|
|||
"100%": { transform: "translateY(0)" },
|
||||
});
|
||||
|
||||
export const wrapper = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
export const wrapper = recipe({
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transition: "all ease 0.3s",
|
||||
},
|
||||
variants: {
|
||||
blurredContent: {
|
||||
true: {
|
||||
filter: "blur(20px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const hero = style({
|
||||
|
@ -68,6 +79,11 @@ export const heroImage = style({
|
|||
},
|
||||
});
|
||||
|
||||
export const gameLogo = style({
|
||||
width: 300,
|
||||
alignSelf: "flex-end",
|
||||
});
|
||||
|
||||
export const heroImageSkeleton = style({
|
||||
height: "300px",
|
||||
"@media": {
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
|||
|
||||
import { GameRepack, GameShop, Steam250Game } from "@types";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import { Button, ConfirmationModal } from "@renderer/components";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
|
||||
import starsAnimation from "@renderer/assets/lottie/stars.json";
|
||||
|
@ -83,6 +83,8 @@ export function GameDetails() {
|
|||
shop,
|
||||
showRepacksModal,
|
||||
showGameOptionsModal,
|
||||
hasNSFWContentBlocked,
|
||||
setHasNSFWContentBlocked,
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
setShowGameOptionsModal,
|
||||
|
@ -107,6 +109,10 @@ export function GameDetails() {
|
|||
setShowGameOptionsModal(false);
|
||||
};
|
||||
|
||||
const handleNSFWContentRefuse = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.background}
|
||||
|
@ -120,6 +126,19 @@ export function GameDetails() {
|
|||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={hasNSFWContentBlocked}
|
||||
onClose={handleNSFWContentRefuse}
|
||||
title={t("nsfw_content_title")}
|
||||
descriptionText={t("nsfw_content_description", {
|
||||
title: gameTitle,
|
||||
})}
|
||||
confirmButtonLabel={t("allow_nsfw_content")}
|
||||
cancelButtonLabel={t("refuse_nsfw_content")}
|
||||
onConfirm={() => setHasNSFWContentBlocked(false)}
|
||||
clickOutsideToClose={false}
|
||||
/>
|
||||
|
||||
{game && (
|
||||
<GameOptionsModal
|
||||
visible={showGameOptionsModal}
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useContext, useEffect, useMemo, useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "./hero-panel.css";
|
||||
import { formatDownloadProgress } from "@renderer/helpers";
|
||||
import { useDate, useDownload } from "@renderer/hooks";
|
||||
import { useDate, useDownload, useFormat } from "@renderer/hooks";
|
||||
import { Link } from "@renderer/components";
|
||||
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
|
@ -13,7 +13,9 @@ export function HeroPanelPlaytime() {
|
|||
|
||||
const { game, isGameRunning } = useContext(gameDetailsContext);
|
||||
|
||||
const { i18n, t } = useTranslation("game_details");
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const { progress, lastPacket } = useDownload();
|
||||
|
||||
|
@ -29,12 +31,6 @@ export function HeroPanelPlaytime() {
|
|||
}
|
||||
}, [game?.lastTimePlayed, formatDistance]);
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}, [i18n.language]);
|
||||
|
||||
const formattedPlayTime = useMemo(() => {
|
||||
const milliseconds = game?.playTimeInMilliseconds || 0;
|
||||
const seconds = milliseconds / 1000;
|
||||
|
|
|
@ -70,7 +70,7 @@ export const howLongToBeatCategory = style({
|
|||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
backgroundColor: vars.color.background,
|
||||
borderRadius: "8px",
|
||||
borderRadius: "4px",
|
||||
padding: `8px 16px`,
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
});
|
||||
|
@ -81,10 +81,32 @@ export const howLongToBeatCategoryLabel = style({
|
|||
|
||||
export const howLongToBeatCategorySkeleton = style({
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
borderRadius: "8px",
|
||||
borderRadius: "4px",
|
||||
height: "76px",
|
||||
});
|
||||
|
||||
export const statsSection = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
justifyContent: "space-between",
|
||||
});
|
||||
|
||||
export const statsCategoryTitle = style({
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
||||
|
||||
export const statsCategory = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
alignItems: "flex-end",
|
||||
});
|
||||
|
||||
globalStyle(`${requirementsDetails} a`, {
|
||||
display: "flex",
|
||||
color: vars.color.body,
|
||||
|
|
|
@ -6,6 +6,8 @@ import { Button } from "@renderer/components";
|
|||
|
||||
import * as styles from "./sidebar.css";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
|
||||
|
||||
export function Sidebar() {
|
||||
const [howLongToBeat, setHowLongToBeat] = useState<{
|
||||
|
@ -21,6 +23,8 @@ export function Sidebar() {
|
|||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
useEffect(() => {
|
||||
if (objectID) {
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
|
@ -43,18 +47,41 @@ export function Sidebar() {
|
|||
isLoading={howLongToBeat.isLoading}
|
||||
/>
|
||||
|
||||
<div className={styles.contentSidebarTitle} style={{ border: "none" }}>
|
||||
<h3>{t("stats")}</h3>
|
||||
</div>
|
||||
{stats && (
|
||||
<>
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<h3>{t("stats")}</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>downloadCount {stats?.downloadCount}</p>
|
||||
<p>playerCount {stats?.playerCount}</p>
|
||||
</div>
|
||||
<div className={styles.statsSection}>
|
||||
<div className={styles.statsCategory}>
|
||||
<p className={styles.statsCategoryTitle}>
|
||||
<DownloadIcon size={18} />
|
||||
{t("download_count")}
|
||||
</p>
|
||||
<p>{numberFormatter.format(stats?.downloadCount)}</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentSidebarTitle} style={{ border: "none" }}>
|
||||
<h3>{t("requirements")}</h3>
|
||||
</div>
|
||||
<div className={styles.statsCategory}>
|
||||
<p className={styles.statsCategoryTitle}>
|
||||
<PeopleIcon size={18} />
|
||||
{t("player_count")}
|
||||
</p>
|
||||
<p>{numberFormatter.format(stats?.playerCount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.contentSidebarTitle}
|
||||
style={{ border: "none" }}
|
||||
>
|
||||
<h3>{t("requirements")}</h3>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.requirementButtonContainer}>
|
||||
<Button
|
||||
|
|
|
@ -67,6 +67,12 @@ export function Home() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCategoryClick = (category: CatalogueCategory) => {
|
||||
if (category !== currentCatalogueCategory) {
|
||||
getCatalogue(category);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
getCatalogue(CatalogueCategory.Hot);
|
||||
|
@ -93,7 +99,7 @@ export function Home() {
|
|||
? "primary"
|
||||
: "outline"
|
||||
}
|
||||
onClick={() => getCatalogue(category)}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
>
|
||||
{t(category)}
|
||||
</Button>
|
||||
|
|
|
@ -7,7 +7,7 @@ import type { DebouncedFunc } from "lodash";
|
|||
import { debounce } from "lodash";
|
||||
|
||||
import { InboxIcon, SearchIcon } from "@primer/octicons-react";
|
||||
import { clearSearch } from "@renderer/features";
|
||||
import { clearSearch, setSearch } from "@renderer/features";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
@ -37,6 +37,10 @@ export function SearchResults() {
|
|||
navigate(buildGameDetailsPath(game));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setSearch(searchParams.get("query") ?? ""));
|
||||
}, [dispatch, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
if (debouncedFunc.current) debouncedFunc.current.cancel();
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { vars } from "../../../theme.css";
|
||||
import { globalStyle, style } from "@vanilla-extract/css";
|
||||
|
||||
export const profileAvatarEditContainer = style({
|
||||
alignSelf: "center",
|
||||
width: "128px",
|
||||
height: "128px",
|
||||
display: "flex",
|
||||
borderRadius: "4px",
|
||||
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({
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const profileAvatarEditOverlay = style({
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
color: vars.color.muted,
|
||||
zIndex: "1",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
transition: "all ease 0.2s",
|
||||
alignItems: "center",
|
||||
opacity: "0",
|
||||
});
|
||||
|
||||
globalStyle(`${profileAvatarEditContainer}:hover ${profileAvatarEditOverlay}`, {
|
||||
opacity: "1",
|
||||
});
|
|
@ -0,0 +1,177 @@
|
|||
import { useContext, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
Button,
|
||||
Link,
|
||||
Modal,
|
||||
ModalProps,
|
||||
TextField,
|
||||
} from "@renderer/components";
|
||||
import { useAppSelector, useToast, useUserDetails } from "@renderer/hooks";
|
||||
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
import * as styles from "./edit-profile-modal.css";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
|
||||
interface FormValues {
|
||||
profileImageUrl?: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function EditProfileModal(
|
||||
props: Omit<ModalProps, "children" | "title">
|
||||
) {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const schema = yup.object({
|
||||
displayName: yup
|
||||
.string()
|
||||
.required(t("required_field"))
|
||||
.min(3, t("displayname_min_length"))
|
||||
.max(50, t("displayname_max_length")),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
const { getUserProfile } = useContext(userProfileContext);
|
||||
|
||||
const { userDetails } = useAppSelector((state) => state.userDetails);
|
||||
const { fetchUserDetails } = useUserDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails) {
|
||||
setValue("displayName", userDetails.displayName);
|
||||
}
|
||||
}, [setValue, userDetails]);
|
||||
|
||||
const { patchUser } = useUserDetails();
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
return patchUser(values)
|
||||
.then(async () => {
|
||||
await Promise.allSettled([fetchUserDetails(), getUserProfile()]);
|
||||
showSuccessToast(t("saved_successfully"));
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("try_again"));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal {...props} title={t("edit_profile")} clickOutsideToClose={false}>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
width: "350px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="profileImageUrl"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const handleChangeProfileAvatar = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["jpg", "jpeg", "png"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
|
||||
onChange(path);
|
||||
}
|
||||
};
|
||||
|
||||
const getImageUrl = () => {
|
||||
if (value) return `local:${value}`;
|
||||
if (userDetails?.profileImageUrl)
|
||||
return userDetails.profileImageUrl;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const imageUrl = getImageUrl();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.profileAvatarEditContainer}
|
||||
onClick={handleChangeProfileAvatar}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
className={styles.profileAvatar}
|
||||
alt={userDetails?.displayName}
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<PersonIcon size={96} />
|
||||
)}
|
||||
|
||||
<div className={styles.profileAvatarEditOverlay}>
|
||||
<DeviceCameraIcon size={38} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
{...register("displayName")}
|
||||
label={t("display_name")}
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
containerProps={{ style: { width: "100%" } }}
|
||||
error={errors.displayName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small style={{ marginTop: `${SPACING_UNIT * 2}px` }}>
|
||||
<Trans i18nKey="privacy_hint" ns="user_profile">
|
||||
<Link to="/settings" />
|
||||
</Trans>
|
||||
</small>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
style={{ alignSelf: "end", marginTop: `${SPACING_UNIT * 3}px` }}
|
||||
type="submit"
|
||||
>
|
||||
{isSubmitting ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -11,8 +11,8 @@ export function LockedProfile() {
|
|||
<div className={styles.lockIcon}>
|
||||
<LockIcon size={24} />
|
||||
</div>
|
||||
|
||||
<h2>{t("locked_profile")}</h2>
|
||||
<p>{t("locked_profile_description")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { userProfileContext } from "@renderer/context";
|
||||
import { useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch } from "@renderer/hooks";
|
||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
@ -17,11 +17,11 @@ import { useNavigate } from "react-router-dom";
|
|||
import { LockedProfile } from "./locked-profile";
|
||||
|
||||
export function ProfileContent() {
|
||||
const { userProfile } = useContext(userProfileContext);
|
||||
const { userProfile, isMe } = useContext(userProfileContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { i18n, t } = useTranslation("user_profile");
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile) {
|
||||
|
@ -34,11 +34,7 @@ export function ProfileContent() {
|
|||
return userProfile?.libraryGames.slice(0, 12);
|
||||
}, [userProfile]);
|
||||
|
||||
const numberFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}, [i18n.language]);
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
@ -65,10 +61,18 @@ export function ProfileContent() {
|
|||
objectID: game.objectId,
|
||||
});
|
||||
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!userProfile) return null;
|
||||
|
||||
if (userProfile?.profileVisibility === "FRIENDS") {
|
||||
const shouldLockProfile =
|
||||
userProfile.profileVisibility === "PRIVATE" ||
|
||||
(userProfile.profileVisibility === "FRIENDS" && !usersAreFriends);
|
||||
|
||||
if (!isMe && shouldLockProfile) {
|
||||
return <LockedProfile />;
|
||||
}
|
||||
|
||||
|
@ -213,6 +217,8 @@ export function ProfileContent() {
|
|||
numberFormatter,
|
||||
t,
|
||||
truncatedGamesList,
|
||||
usersAreFriends,
|
||||
isMe,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ export const profileAvatarButton = style({
|
|||
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
|
||||
cursor: "pointer",
|
||||
transition: "all ease 0.3s",
|
||||
color: vars.color.muted,
|
||||
":hover": {
|
||||
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.7)",
|
||||
},
|
||||
|
@ -69,3 +70,16 @@ export const userInformation = style({
|
|||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
});
|
||||
|
||||
export const currentGameWrapper = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
});
|
||||
|
||||
export const currentGameDetails = style({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
});
|
||||
|
|
|
@ -18,7 +18,7 @@ import { addSeconds } from "date-fns";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { FriendRequestAction } from "@types";
|
||||
import { UserProfileSettingsModal } from "../user-profile-settings-modal";
|
||||
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
|
||||
|
||||
type FriendAction =
|
||||
| FriendRequestAction
|
||||
|
@ -26,6 +26,7 @@ type FriendAction =
|
|||
|
||||
export function ProfileHero() {
|
||||
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false);
|
||||
|
||||
const context = useContext(userProfileContext);
|
||||
const {
|
||||
|
@ -49,14 +50,22 @@ export function ProfileHero() {
|
|||
const navigate = useNavigate();
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
await signOut();
|
||||
setIsPerformingAction(true);
|
||||
|
||||
showSuccessToast(t("successfully_signed_out"));
|
||||
try {
|
||||
await signOut();
|
||||
|
||||
showSuccessToast(t("successfully_signed_out"));
|
||||
} finally {
|
||||
setIsPerformingAction(false);
|
||||
}
|
||||
navigate("/");
|
||||
}, [navigate, signOut, showSuccessToast, t]);
|
||||
|
||||
const handleFriendAction = useCallback(
|
||||
(userId: string, action: FriendAction) => {
|
||||
setIsPerformingAction(true);
|
||||
|
||||
try {
|
||||
if (action === "UNDO_FRIENDSHIP") {
|
||||
undoFriendship(userId).then(getUserProfile);
|
||||
|
@ -80,6 +89,8 @@ export function ProfileHero() {
|
|||
updateFriendRequestState(userId, action).then(getUserProfile);
|
||||
} catch (err) {
|
||||
showErrorToast(t("try_again"));
|
||||
} finally {
|
||||
setIsPerformingAction(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
|
@ -100,12 +111,20 @@ export function ProfileHero() {
|
|||
if (isMe) {
|
||||
return (
|
||||
<>
|
||||
<Button theme="outline" onClick={() => setShowEditProfileModal(true)}>
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() => setShowEditProfileModal(true)}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<PencilIcon />
|
||||
{t("edit_profile")}
|
||||
</Button>
|
||||
|
||||
<Button theme="danger" onClick={handleSignOut}>
|
||||
<Button
|
||||
theme="danger"
|
||||
onClick={handleSignOut}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<SignOutIcon />
|
||||
{t("sign_out")}
|
||||
</Button>
|
||||
|
@ -119,6 +138,7 @@ export function ProfileHero() {
|
|||
<Button
|
||||
theme="outline"
|
||||
onClick={() => handleFriendAction(userProfile.id, "SEND")}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
{t("add_friend")}
|
||||
</Button>
|
||||
|
@ -126,6 +146,7 @@ export function ProfileHero() {
|
|||
<Button
|
||||
theme="danger"
|
||||
onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
{t("block_user")}
|
||||
</Button>
|
||||
|
@ -138,6 +159,7 @@ export function ProfileHero() {
|
|||
<Button
|
||||
theme="outline"
|
||||
onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<XCircleFillIcon />
|
||||
{t("undo_friendship")}
|
||||
|
@ -152,6 +174,7 @@ export function ProfileHero() {
|
|||
onClick={() =>
|
||||
handleFriendAction(userProfile.relation!.BId, "CANCEL")
|
||||
}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<XCircleFillIcon /> {t("cancel_request")}
|
||||
</Button>
|
||||
|
@ -165,6 +188,7 @@ export function ProfileHero() {
|
|||
onClick={() =>
|
||||
handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
|
||||
}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<CheckCircleFillIcon /> {t("accept_request")}
|
||||
</Button>
|
||||
|
@ -173,12 +197,20 @@ export function ProfileHero() {
|
|||
onClick={() =>
|
||||
handleFriendAction(userProfile.relation!.AId, "REFUSED")
|
||||
}
|
||||
disabled={isPerformingAction}
|
||||
>
|
||||
<XCircleFillIcon /> {t("ignore_request")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}, [handleFriendAction, handleSignOut, isMe, t, userProfile]);
|
||||
}, [
|
||||
handleFriendAction,
|
||||
handleSignOut,
|
||||
isMe,
|
||||
t,
|
||||
isPerformingAction,
|
||||
userProfile,
|
||||
]);
|
||||
|
||||
const handleAvatarClick = useCallback(() => {
|
||||
if (isMe) {
|
||||
|
@ -196,10 +228,8 @@ export function ProfileHero() {
|
|||
cancelButtonLabel={t("cancel")}
|
||||
/> */}
|
||||
|
||||
<UserProfileSettingsModal
|
||||
<EditProfileModal
|
||||
visible={showEditProfileModal}
|
||||
userProfile={userProfile}
|
||||
updateUserProfile={getUserProfile}
|
||||
onClose={() => setShowEditProfileModal(false)}
|
||||
/>
|
||||
|
||||
|
@ -230,21 +260,8 @@ export function ProfileHero() {
|
|||
</h2>
|
||||
|
||||
{currentGame && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT / 2}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div className={styles.currentGameWrapper}>
|
||||
<div className={styles.currentGameDetails}>
|
||||
<Link
|
||||
to={buildGameDetailsPath({
|
||||
...currentGame,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { SPACING_UNIT, vars } from "../../theme.css";
|
||||
import { globalStyle, style } from "@vanilla-extract/css";
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const wrapper = style({
|
||||
width: "100%",
|
||||
|
@ -7,47 +7,3 @@ export const wrapper = style({
|
|||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 3}px`,
|
||||
});
|
||||
|
||||
/* Legacy styles */
|
||||
export const profileAvatarEditContainer = style({
|
||||
alignSelf: "center",
|
||||
width: "128px",
|
||||
height: "128px",
|
||||
display: "flex",
|
||||
borderRadius: "4px",
|
||||
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({
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
objectFit: "cover",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const profileAvatarEditOverlay = style({
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
color: vars.color.muted,
|
||||
zIndex: "1",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
transition: "all ease 0.2s",
|
||||
alignItems: "center",
|
||||
opacity: "0",
|
||||
});
|
||||
|
||||
globalStyle(`${profileAvatarEditContainer}:hover ${profileAvatarEditOverlay}`, {
|
||||
opacity: "1",
|
||||
});
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
export * from "./user-profile-settings-modal";
|
|
@ -1,118 +0,0 @@
|
|||
import { SPACING_UNIT, vars } from "@renderer/theme.css";
|
||||
import { UserFriend } from "@types";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendItem } from "@renderer/pages/shared-modals/user-friend-modal/user-friend-item";
|
||||
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
|
||||
|
||||
const pageSize = 12;
|
||||
|
||||
export const UserEditProfileBlockList = () => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { showErrorToast } = useToast();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [maxPage, setMaxPage] = useState(0);
|
||||
const [blocks, setBlocks] = useState<UserFriend[]>([]);
|
||||
const listContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { unblockUser } = useUserDetails();
|
||||
|
||||
const loadNextPage = () => {
|
||||
if (page > maxPage) return;
|
||||
setIsLoading(true);
|
||||
window.electron
|
||||
.getUserBlocks(pageSize, page * pageSize)
|
||||
.then((newPage) => {
|
||||
if (page === 0) {
|
||||
setMaxPage(newPage.totalBlocks / pageSize);
|
||||
}
|
||||
|
||||
setBlocks([...blocks, ...newPage.blocks]);
|
||||
setPage(page + 1);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = listContainer.current?.scrollTop || 0;
|
||||
const scrollHeight = listContainer.current?.scrollHeight || 0;
|
||||
const clientHeight = listContainer.current?.clientHeight || 0;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
|
||||
if (scrollTop < maxScrollTop * 0.9 || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadNextPage();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = listContainer.current;
|
||||
container?.addEventListener("scroll", handleScroll);
|
||||
return () => container?.removeEventListener("scroll", handleScroll);
|
||||
}, [isLoading]);
|
||||
|
||||
const reloadList = () => {
|
||||
setPage(0);
|
||||
setMaxPage(0);
|
||||
setBlocks([]);
|
||||
loadNextPage();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadList();
|
||||
}, []);
|
||||
|
||||
const handleUnblock = (userId: string) => {
|
||||
unblockUser(userId)
|
||||
.then(() => {
|
||||
reloadList();
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("try_again"));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
|
||||
<div
|
||||
ref={listContainer}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
maxHeight: "400px",
|
||||
overflowY: "scroll",
|
||||
}}
|
||||
>
|
||||
{!isLoading && blocks.length === 0 && <p>{t("no_blocked_users")}</p>}
|
||||
{blocks.map((friend) => {
|
||||
return (
|
||||
<UserFriendItem
|
||||
userId={friend.id}
|
||||
displayName={friend.displayName}
|
||||
profileImageUrl={friend.profileImageUrl}
|
||||
onClickUnblock={handleUnblock}
|
||||
type={"BLOCKED"}
|
||||
key={friend.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<Skeleton
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "54px",
|
||||
overflow: "hidden",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
|
@ -1,151 +0,0 @@
|
|||
import { DeviceCameraIcon, PersonIcon } from "@primer/octicons-react";
|
||||
import { Button, SelectField, TextField } from "@renderer/components";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { UserProfile } from "@types";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as styles from "../profile.css";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
|
||||
export interface UserEditProfileProps {
|
||||
userProfile: UserProfile;
|
||||
updateUserProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const UserEditProfile = ({
|
||||
userProfile,
|
||||
updateUserProfile,
|
||||
}: UserEditProfileProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const [form, setForm] = useState({
|
||||
displayName: userProfile.displayName,
|
||||
profileVisibility: userProfile.profileVisibility,
|
||||
profileImageUrl: null as string | null,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const { patchUser } = useUserDetails();
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [profileVisibilityOptions, setProfileVisibilityOptions] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileVisibilityOptions([
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
{ value: "FRIENDS", label: t("friends_only") },
|
||||
{ value: "PRIVATE", label: t("private") },
|
||||
]);
|
||||
}, [t]);
|
||||
|
||||
const handleChangeProfileAvatar = async () => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
filters: [
|
||||
{
|
||||
name: "Image",
|
||||
extensions: ["jpg", "jpeg", "png"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (filePaths && filePaths.length > 0) {
|
||||
const path = filePaths[0];
|
||||
|
||||
setForm({ ...form, profileImageUrl: path });
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileVisibilityChange = (event) => {
|
||||
setForm({
|
||||
...form,
|
||||
profileVisibility: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveProfile: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
patchUser(form)
|
||||
.then(async () => {
|
||||
await updateUserProfile();
|
||||
showSuccessToast(t("saved_successfully"));
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("try_again"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (form.profileImageUrl) return `local:${form.profileImageUrl}`;
|
||||
if (userProfile.profileImageUrl) return userProfile.profileImageUrl;
|
||||
return null;
|
||||
}, [form, userProfile]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSaveProfile}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "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.profileAvatarEditOverlay}>
|
||||
<DeviceCameraIcon size={38} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<TextField
|
||||
label={t("display_name")}
|
||||
value={form.displayName}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
containerProps={{ style: { width: "100%" } }}
|
||||
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label={t("privacy")}
|
||||
value={form.profileVisibility}
|
||||
onChange={handleProfileVisibilityChange}
|
||||
options={profileVisibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Button disabled={isSaving} style={{ alignSelf: "end" }} type="submit">
|
||||
{isSaving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
|
@ -1,78 +0,0 @@
|
|||
import { Button, Modal } from "@renderer/components";
|
||||
import { UserProfile } from "@types";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserEditProfile } from "./user-edit-profile";
|
||||
import { UserEditProfileBlockList } from "./user-block-list";
|
||||
|
||||
export interface UserProfileSettingsModalProps {
|
||||
userProfile: UserProfile;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
updateUserProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const UserProfileSettingsModal = ({
|
||||
userProfile,
|
||||
visible,
|
||||
onClose,
|
||||
updateUserProfile,
|
||||
}: UserProfileSettingsModalProps) => {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
const tabs = [t("edit_profile"), t("blocked_users")];
|
||||
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
|
||||
const renderTab = () => {
|
||||
if (currentTabIndex == 0) {
|
||||
return (
|
||||
<UserEditProfile
|
||||
userProfile={userProfile}
|
||||
updateUserProfile={updateUserProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentTabIndex == 1) {
|
||||
return <UserEditProfileBlockList />;
|
||||
}
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("settings")}
|
||||
onClose={onClose}
|
||||
clickOutsideToClose={false}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT * 2}px`,
|
||||
}}
|
||||
>
|
||||
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
|
||||
{tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab}
|
||||
theme={index === currentTabIndex ? "primary" : "outline"}
|
||||
onClick={() => setCurrentTabIndex(index)}
|
||||
>
|
||||
{tab}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
{renderTab()}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
9
src/renderer/src/pages/settings/settings-privacy.css.ts
Normal file
9
src/renderer/src/pages/settings/settings-privacy.css.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT } from "../../theme.css";
|
||||
|
||||
export const form = style({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
});
|
77
src/renderer/src/pages/settings/settings-privacy.tsx
Normal file
77
src/renderer/src/pages/settings/settings-privacy.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { Button, SelectField } from "@renderer/components";
|
||||
import { SPACING_UNIT } from "@renderer/theme.css";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import * as styles from "./settings-privacy.css";
|
||||
import { useToast, useUserDetails } from "@renderer/hooks";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface FormValues {
|
||||
profileVisibility: "PUBLIC" | "FRIENDS" | "PRIVATE";
|
||||
}
|
||||
|
||||
export function SettingsPrivacy() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
setValue,
|
||||
handleSubmit,
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const { patchUser, userDetails } = useUserDetails();
|
||||
|
||||
useEffect(() => {
|
||||
if (userDetails?.profileVisibility) {
|
||||
setValue("profileVisibility", userDetails.profileVisibility);
|
||||
}
|
||||
}, [userDetails, setValue]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
{ value: "FRIENDS", label: t("friends_only") },
|
||||
{ value: "PRIVATE", label: t("private") },
|
||||
];
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await patchUser(values);
|
||||
showSuccessToast(t("changes_saved"));
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="profileVisibility"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<SelectField
|
||||
label={t("profile_visibility")}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
options={visibilityOptions.map((visiblity) => ({
|
||||
key: visiblity.value,
|
||||
value: visiblity.value,
|
||||
label: visiblity.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<small>{t("profile_visibility_description")}</small>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
style={{ alignSelf: "flex-end", marginTop: `${SPACING_UNIT * 2}px` }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -11,6 +11,7 @@ import {
|
|||
SettingsContextConsumer,
|
||||
SettingsContextProvider,
|
||||
} from "@renderer/context";
|
||||
import { SettingsPrivacy } from "./settings-privacy";
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation("settings");
|
||||
|
@ -20,6 +21,7 @@ export function Settings() {
|
|||
t("behavior"),
|
||||
t("download_sources"),
|
||||
"Real-Debrid",
|
||||
t("privacy"),
|
||||
];
|
||||
|
||||
return (
|
||||
|
@ -39,7 +41,11 @@ export function Settings() {
|
|||
return <SettingsDownloadSources />;
|
||||
}
|
||||
|
||||
return <SettingsRealDebrid />;
|
||||
if (currentCategoryIndex === 3) {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsPrivacy />;
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue