feat: moving visibility update to settings

This commit is contained in:
Chubby Granny Chaser 2024-09-13 23:56:27 +01:00
parent 383578bca2
commit 2b2b5afd79
No known key found for this signature in database
51 changed files with 1096 additions and 10511 deletions

View file

@ -0,0 +1,58 @@
// 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
};

View file

@ -35,6 +35,7 @@
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.0.22", "@fontsource/noto-sans": "^5.0.22",
"@hookform/resolvers": "^3.9.0",
"@primer/octicons-react": "^19.9.0", "@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0", "@sentry/electron": "^5.1.0",
@ -65,6 +66,7 @@
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"parse-torrent": "^11.0.16", "parse-torrent": "^11.0.16",
"piscina": "^4.5.1", "piscina": "^4.5.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0", "react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1", "react-redux": "^9.1.1",
@ -73,6 +75,7 @@
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"user-agents": "^1.1.193", "user-agents": "^1.1.193",
"yaml": "^2.4.1", "yaml": "^2.4.1",
"yup": "^1.4.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,9 @@
"trending": "Trending", "trending": "Trending",
"surprise_me": "Surprise me", "surprise_me": "Surprise me",
"no_results": "No results found", "no_results": "No results found",
"start_typing": "Starting typing to search..." "start_typing": "Starting typing to search...",
"hot": "🔥 Hot now",
"weekly": "📅 Top games of the week"
}, },
"sidebar": { "sidebar": {
"catalogue": "Catalogue", "catalogue": "Catalogue",
@ -116,7 +118,14 @@
"download_paused": "Download paused", "download_paused": "Download paused",
"last_downloaded_option": "Last downloaded option", "last_downloaded_option": "Last downloaded option",
"create_shortcut_success": "Shortcut created successfully", "create_shortcut_success": "Shortcut created successfully",
"create_shortcut_error": "Error creating shortcut" "create_shortcut_error": "Error creating shortcut",
"nsfw_content_title": "This game contains innapropriate content",
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
"allow_nsfw_content": "Continue",
"refuse_nsfw_content": "Go back",
"stats": "Stats",
"download_count": "Downloads",
"player_count": "Active players"
}, },
"activation": { "activation": {
"title": "Activate Hydra", "title": "Activate Hydra",
@ -192,7 +201,13 @@
"found_download_option_zero": "No download option found", "found_download_option_zero": "No download option found",
"found_download_option_one": "Found {{countFormatted}} download option", "found_download_option_one": "Found {{countFormatted}} download option",
"found_download_option_other": "Found {{countFormatted}} download options", "found_download_option_other": "Found {{countFormatted}} download options",
"import": "Import" "import": "Import",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
"privacy": "Privacy",
"profile_visibility": "Profile visibility",
"profile_visibility_description": "Choose who can see your profile and library"
}, },
"notifications": { "notifications": {
"download_complete": "Download complete", "download_complete": "Download complete",
@ -262,11 +277,6 @@
"request_accepted": "Request accepted", "request_accepted": "Request accepted",
"user_blocked_successfully": "User blocked successfully", "user_blocked_successfully": "User blocked successfully",
"user_block_modal_text": "This will block {{displayName}}", "user_block_modal_text": "This will block {{displayName}}",
"settings": "Settings",
"public": "Public",
"private": "Private",
"friends_only": "Friends only",
"privacy": "Privacy",
"blocked_users": "Blocked users", "blocked_users": "Blocked users",
"unblock": "Unblock", "unblock": "Unblock",
"no_friends_added": "You still don't have added friends", "no_friends_added": "You still don't have added friends",
@ -274,6 +284,8 @@
"no_pending_invites": "You have no pending invites", "no_pending_invites": "You have no pending invites",
"no_blocked_users": "You have no blocked users", "no_blocked_users": "You have no blocked users",
"friend_code_copied": "Friend code copied", "friend_code_copied": "Friend code copied",
"undo_friendship_modal_text": "This will undo your friendship with {{displayName}}" "undo_friendship_modal_text": "This will undo your friendship with {{displayName}}",
"privacy_hint": "To adjust who can see this, go to the <0>Settings</0>",
"locked_profile": "This profile is private"
} }
} }

View file

@ -114,7 +114,14 @@
"download_paused": "Download pausado", "download_paused": "Download pausado",
"last_downloaded_option": "Última opção baixada", "last_downloaded_option": "Última opção baixada",
"create_shortcut_success": "Atalho criado com sucesso", "create_shortcut_success": "Atalho criado com sucesso",
"create_shortcut_error": "Erro ao criar atalho" "create_shortcut_error": "Erro ao criar atalho",
"nsfw_content_title": "Este jogo contém conteúdo inapropriado",
"nsfw_content_description": "{{title}} contém conteúdo que pode não ser apropriado para todas as idades. Você deseja continuar?",
"allow_nsfw_content": "Continuar",
"refuse_nsfw_content": "Voltar",
"stats": "Estatísticas",
"download_count": "Downloads",
"player_count": "Jogadores ativos"
}, },
"activation": { "activation": {
"title": "Ativação", "title": "Ativação",
@ -193,7 +200,13 @@
"found_download_option_zero": "Nenhuma opção de download encontrada", "found_download_option_zero": "Nenhuma opção de download encontrada",
"found_download_option_one": "{{countFormatted}} opção de download encontrada", "found_download_option_one": "{{countFormatted}} opção de download encontrada",
"found_download_option_other": "{{countFormatted}} opções de download encontradas", "found_download_option_other": "{{countFormatted}} opções de download encontradas",
"import": "Importar" "import": "Importar",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"profile_visibility": "Visibilidade do perfil",
"profile_visibility_description": "Escolha quem pode ver seu perfil e biblioteca"
}, },
"notifications": { "notifications": {
"download_complete": "Download concluído", "download_complete": "Download concluído",
@ -267,11 +280,6 @@
"request_accepted": "Pedido de amizade aceito", "request_accepted": "Pedido de amizade aceito",
"user_blocked_successfully": "Usuário bloqueado com sucesso", "user_blocked_successfully": "Usuário bloqueado com sucesso",
"user_block_modal_text": "Bloquear {{displayName}}", "user_block_modal_text": "Bloquear {{displayName}}",
"settings": "Configurações",
"privacy": "Privacidade",
"private": "Privado",
"friends_only": "Apenas amigos",
"public": "Público",
"blocked_users": "Usuários bloqueados", "blocked_users": "Usuários bloqueados",
"unblock": "Desbloquear", "unblock": "Desbloquear",
"no_friends_added": "Você ainda não possui amigos adicionados", "no_friends_added": "Você ainda não possui amigos adicionados",
@ -279,6 +287,8 @@
"no_pending_invites": "Você não possui convites de amizade pendentes", "no_pending_invites": "Você não possui convites de amizade pendentes",
"no_blocked_users": "Você não tem nenhum usuário bloqueado", "no_blocked_users": "Você não tem nenhum usuário bloqueado",
"friend_code_copied": "Código de amigo copiado", "friend_code_copied": "Código de amigo copiado",
"undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}" "undo_friendship_modal_text": "Isso irá remover sua amizade com {{displayName}}",
"privacy_hint": "Pra controlar quem pode ver seu perfil, acesse a <0>Tela de Configurações</0>",
"profile_locked": "Este perfil é privado"
} }
} }

View file

@ -1,6 +1,11 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main"; import * as Sentry from "@sentry/electron/main";
import { HydraApi, PythonInstance, gamesPlaytime } from "@main/services"; import {
DownloadManager,
HydraApi,
PythonInstance,
gamesPlaytime,
} from "@main/services";
import { dataSource } from "@main/data-source"; import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth } from "@main/entity"; import { DownloadQueue, Game, UserAuth } from "@main/entity";
@ -23,6 +28,9 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
/* Removes user from Sentry */ /* Removes user from Sentry */
Sentry.setUser(null); Sentry.setUser(null);
/* Cancels any ongoing downloads */
DownloadManager.cancelDownload();
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.killTorrent(); PythonInstance.killTorrent();

View file

@ -16,6 +16,7 @@ const getCatalogue = async (
const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>( const response = await HydraApi.get<{ objectId: string; shop: GameShop }[]>(
`/games/${category}?${params.toString()}`, `/games/${category}?${params.toString()}`,
{},
{ needsAuth: false } { needsAuth: false }
); );

View file

@ -11,6 +11,7 @@ import { GenericHttpDownloader } from "./generic-http-downloader";
export class DownloadManager { export class DownloadManager {
private static currentDownloader: Downloader | null = null; private static currentDownloader: Downloader | null = null;
private static downloadingGameId: number | null = null;
public static async watchDownloads() { public static async watchDownloads() {
let status: DownloadProgress | null = null; let status: DownloadProgress | null = null;
@ -76,13 +77,14 @@ export class DownloadManager {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null; this.currentDownloader = null;
this.downloadingGameId = null;
} }
static async resumeDownload(game: Game) { static async resumeDownload(game: Game) {
return this.startDownload(game); return this.startDownload(game);
} }
static async cancelDownload(gameId: number) { static async cancelDownload(gameId = this.downloadingGameId!) {
if (this.currentDownloader === Downloader.Torrent) { if (this.currentDownloader === Downloader.Torrent) {
PythonInstance.cancelDownload(gameId); PythonInstance.cancelDownload(gameId);
} else if (this.currentDownloader === Downloader.RealDebrid) { } else if (this.currentDownloader === Downloader.RealDebrid) {
@ -93,6 +95,7 @@ export class DownloadManager {
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
this.currentDownloader = null; this.currentDownloader = null;
this.downloadingGameId = null;
} }
static async startDownload(game: Game) { static async startDownload(game: Game) {
@ -131,5 +134,6 @@ export class DownloadManager {
} }
this.currentDownloader = game.downloader; this.currentDownloader = game.downloader;
this.downloadingGameId = game.id;
} }
} }

View file

@ -81,3 +81,5 @@ export const publishNotificationUpdateReadyToInstall = async (
icon: trayIcon, icon: trayIcon,
}).show(); }).show();
}; };
export const publishNewFriendRequestNotification = async () => {};

View file

@ -58,11 +58,11 @@ export const button = styleVariants({
danger: [ danger: [
base, base,
{ {
border: `solid 1px #a31533`, borderColor: "transparent",
backgroundColor: "transparent",
color: "white",
":hover": {
backgroundColor: "#a31533", backgroundColor: "#a31533",
color: "#c0c1c7",
":hover": {
backgroundColor: "#b3203f",
}, },
}, },
], ],

View file

@ -3,26 +3,44 @@ import { Modal, type ModalProps } from "../modal/modal";
import * as styles from "./confirmation-modal.css"; import * as styles from "./confirmation-modal.css";
export interface ConfirmationModalProps extends ModalProps { export interface ConfirmationModalProps extends Omit<ModalProps, "children"> {
confirmButtonLabel: string; confirmButtonLabel: string;
cancelButtonLabel: string; cancelButtonLabel: string;
descriptionText: string; descriptionText: string;
onConfirm: () => void;
onCancel?: () => void;
} }
export function ConfirmationModal({ export function ConfirmationModal({
confirmButtonLabel, confirmButtonLabel,
cancelButtonLabel, cancelButtonLabel,
descriptionText, descriptionText,
onConfirm,
onCancel,
...props ...props
}: ConfirmationModalProps) { }: ConfirmationModalProps) {
const handleCancelClick = () => {
if (onCancel) {
onCancel();
return;
}
props.onClose();
};
return ( return (
<Modal {...props}> <Modal {...props}>
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<p className={styles.descriptionText}>{descriptionText}</p> <p className={styles.descriptionText}>{descriptionText}</p>
<div className={styles.actions}> <div className={styles.actions}>
<Button theme="danger">{cancelButtonLabel}</Button> <Button theme="outline" onClick={handleCancelClick}>
<Button>{confirmButtonLabel}</Button> {cancelButtonLabel}
</Button>
<Button theme="danger" onClick={onConfirm}>
{confirmButtonLabel}
</Button>
</div> </div>
</div> </div>
</Modal> </Modal>

View file

@ -6,7 +6,8 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import * as styles from "./game-card.css"; import * as styles from "./game-card.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge"; import { Badge } from "../badge/badge";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useState } from "react";
import { useFormat } from "@renderer/hooks";
export interface GameCardProps export interface GameCardProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
@ -25,8 +26,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState<GameStats | null>(null); const [stats, setStats] = useState<GameStats | null>(null);
const { i18n } = useTranslation();
const uniqueRepackers = Array.from( const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker)) new Set(game.repacks.map(({ repacker }) => repacker))
); );
@ -39,11 +38,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
} }
}, [game, stats]); }, [game, stats]);
const numberFormatter = useMemo(() => { const { numberFormatter } = useFormat();
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
return ( return (
<button <button

View file

@ -25,6 +25,9 @@ export const sidebar = recipe({
true: { true: {
paddingTop: `${SPACING_UNIT * 6}px`, paddingTop: `${SPACING_UNIT * 6}px`,
}, },
false: {
paddingTop: `${SPACING_UNIT * 2}px`,
},
}, },
}, },
}); });

View file

@ -149,7 +149,6 @@ export function Sidebar() {
}; };
return ( return (
<>
<aside <aside
ref={sidebarRef} ref={sidebarRef}
className={styles.sidebar({ className={styles.sidebar({
@ -202,8 +201,7 @@ export function Sidebar() {
key={game.id} key={game.id}
className={styles.menuItem({ className={styles.menuItem({
active: active:
location.pathname === location.pathname === `/game/${game.shop}/${game.objectID}`,
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed", muted: game.status === "removed",
})} })}
> >
@ -238,6 +236,5 @@ export function Sidebar() {
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
/> />
</aside> </aside>
</>
); );
} }

View file

@ -22,16 +22,6 @@ export const textField = recipe({
minHeight: "40px", minHeight: "40px",
}, },
variants: { variants: {
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
theme: { theme: {
primary: { primary: {
backgroundColor: vars.color.darkBackground, backgroundColor: vars.color.darkBackground,
@ -40,11 +30,21 @@ export const textField = recipe({
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
}, },
}, },
state: { hasError: {
error: { true: {
borderColor: vars.color.danger, 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", display: "flex",
gap: `${SPACING_UNIT}px`, gap: `${SPACING_UNIT}px`,
}); });
export const errorLabel = style({
color: vars.color.danger,
});

View file

@ -1,9 +1,11 @@
import React, { useId, useMemo, useState } from "react"; import React, { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes"; 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 { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./text-field.css";
export interface TextFieldProps export interface TextFieldProps
extends React.DetailedHTMLProps< extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
@ -21,23 +23,27 @@ export interface TextFieldProps
HTMLDivElement HTMLDivElement
>; >;
rightContent?: React.ReactNode | null; rightContent?: React.ReactNode | null;
state?: NonNullable<RecipeVariants<typeof styles.textField>>["state"]; error?: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
} }
export function TextField({ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
(
{
theme = "primary", theme = "primary",
label, label,
hint, hint,
textFieldProps, textFieldProps,
containerProps, containerProps,
rightContent = null, rightContent = null,
state, error,
...props ...props
}: TextFieldProps) { },
ref
) => {
const id = useId(); const id = useId();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const { t } = useTranslation("forms"); const { t } = useTranslation("forms");
@ -48,21 +54,48 @@ export function TextField({
return props.type ?? "text"; return props.type ?? "text";
}, [props.type, isPasswordVisible]); }, [props.type, isPasswordVisible]);
const hintContent = useMemo(() => {
if (error && error.message)
return (
<small className={styles.errorLabel}>{error.message as string}</small>
);
if (hint) return <small>{hint}</small>;
return null;
}, [hint, error]);
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 ( return (
<div className={styles.textFieldContainer} {...containerProps}> <div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>} {label && <label htmlFor={id}>{label}</label>}
<div className={styles.textFieldWrapper}> <div className={styles.textFieldWrapper}>
<div <div
className={styles.textField({ focused: isFocused, theme, state })} className={styles.textField({
theme,
hasError,
focused: isFocused,
})}
{...textFieldProps} {...textFieldProps}
> >
<input <input
ref={ref}
id={id} id={id}
className={styles.textFieldInput({ readOnly: props.readOnly })} className={styles.textFieldInput({ readOnly: props.readOnly })}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props} {...props}
onFocus={handleFocus}
onBlur={handleBlur}
type={inputType} type={inputType}
/> />
@ -85,7 +118,10 @@ export function TextField({
{rightContent} {rightContent}
</div> </div>
{hint && <small>{hint}</small>} {hintContent}
</div> </div>
); );
} }
);
TextField.displayName = "TextField";

View file

@ -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 { useParams, useSearchParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
@ -15,6 +21,7 @@ import type {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { GameDetailsContext } from "./game-details.context.types"; import { GameDetailsContext } from "./game-details.context.types";
import { SteamContentDescriptor } from "@shared";
export const gameDetailsContext = createContext<GameDetailsContext>({ export const gameDetailsContext = createContext<GameDetailsContext>({
game: null, game: null,
@ -29,11 +36,13 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
showRepacksModal: false, showRepacksModal: false,
showGameOptionsModal: false, showGameOptionsModal: false,
stats: null, stats: null,
hasNSFWContentBlocked: false,
setGameColor: () => {}, setGameColor: () => {},
selectGameExecutable: async () => null, selectGameExecutable: async () => null,
updateGame: async () => {}, updateGame: async () => {},
setShowGameOptionsModal: () => {}, setShowGameOptionsModal: () => {},
setShowRepacksModal: () => {}, setShowRepacksModal: () => {},
setHasNSFWContentBlocked: () => {},
}); });
const { Provider } = gameDetailsContext; const { Provider } = gameDetailsContext;
@ -48,9 +57,10 @@ export function GameDetailsContextProvider({
}: GameDetailsContextProps) { }: GameDetailsContextProps) {
const { objectID, shop } = useParams(); const { objectID, shop } = useParams();
const [shopDetails, setGameDetails] = useState<ShopDetails | null>(null); const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
const [repacks, setRepacks] = useState<GameRepack[]>([]); const [repacks, setRepacks] = useState<GameRepack[]>([]);
const [game, setGame] = useState<Game | null>(null); const [game, setGame] = useState<Game | null>(null);
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
const [stats, setStats] = useState<GameStats | null>(null); const [stats, setStats] = useState<GameStats | null>(null);
@ -97,8 +107,17 @@ export function GameDetailsContextProvider({
window.electron.getGameStats(objectID!, shop as GameShop), window.electron.getGameStats(objectID!, shop as GameShop),
]) ])
.then(([appDetailsResult, repacksResult, statsResult]) => { .then(([appDetailsResult, repacksResult, statsResult]) => {
if (appDetailsResult.status === "fulfilled") if (appDetailsResult.status === "fulfilled") {
setGameDetails(appDetailsResult.value); setShopDetails(appDetailsResult.value);
if (
appDetailsResult.value!.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
)
) {
setHasNSFWContentBlocked(true);
}
}
if (repacksResult.status === "fulfilled") if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value); setRepacks(repacksResult.value);
@ -113,7 +132,7 @@ export function GameDetailsContextProvider({
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]); }, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
useEffect(() => { useEffect(() => {
setGameDetails(null); setShopDetails(null);
setGame(null); setGame(null);
setIsLoading(true); setIsLoading(true);
setisGameRunning(false); setisGameRunning(false);
@ -180,6 +199,8 @@ export function GameDetailsContextProvider({
showGameOptionsModal, showGameOptionsModal,
showRepacksModal, showRepacksModal,
stats, stats,
hasNSFWContentBlocked,
setHasNSFWContentBlocked,
setGameColor, setGameColor,
selectGameExecutable, selectGameExecutable,
updateGame, updateGame,

View file

@ -19,9 +19,11 @@ export interface GameDetailsContext {
showRepacksModal: boolean; showRepacksModal: boolean;
showGameOptionsModal: boolean; showGameOptionsModal: boolean;
stats: GameStats | null; stats: GameStats | null;
hasNSFWContentBlocked: boolean;
setGameColor: React.Dispatch<React.SetStateAction<string>>; setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>; selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>; updateGame: () => Promise<void>;
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>; setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>; setShowGameOptionsModal: React.Dispatch<React.SetStateAction<boolean>>;
setHasNSFWContentBlocked: React.Dispatch<React.SetStateAction<boolean>>;
} }

View file

@ -4,3 +4,4 @@ export * from "./use-date";
export * from "./use-toast"; export * from "./use-toast";
export * from "./redux"; export * from "./redux";
export * from "./use-user-details"; export * from "./use-user-details";
export * from "./use-format";

View 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 };
}

View file

@ -88,7 +88,7 @@ export function useUserDetails() {
[updateUserDetails] [updateUserDetails]
); );
const fetchFriendRequests = useCallback(() => { const fetchFriendRequests = useCallback(async () => {
return window.electron return window.electron
.getFriendRequests() .getFriendRequests()
.then((friendRequests) => { .then((friendRequests) => {
@ -127,13 +127,10 @@ export function useUserDetails() {
[fetchFriendRequests] [fetchFriendRequests]
); );
const undoFriendship = (userId: string) => { const undoFriendship = (userId: string) =>
return window.electron.undoFriendship(userId); window.electron.undoFriendship(userId);
};
const blockUser = (userId: string) => { const blockUser = (userId: string) => window.electron.blockUser(userId);
return window.electron.blockUser(userId);
};
const unblockUser = (userId: string) => { const unblockUser = (userId: string) => {
return window.electron.unblockUser(userId); return window.electron.unblockUser(userId);

View file

@ -21,8 +21,14 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { objectID, shopDetails, game, gameColor, setGameColor } = const {
useContext(gameDetailsContext); objectID,
shopDetails,
game,
gameColor,
setGameColor,
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const [backdropOpactiy, setBackdropOpacity] = useState(1); const [backdropOpactiy, setBackdropOpacity] = useState(1);
@ -64,7 +70,7 @@ export function GameDetailsContent() {
}; };
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img <img
src={steamUrlBuilder.libraryHero(objectID!)} src={steamUrlBuilder.libraryHero(objectID!)}
className={styles.heroImage} className={styles.heroImage}
@ -93,7 +99,7 @@ export function GameDetailsContent() {
<div className={styles.heroContent}> <div className={styles.heroContent}>
<img <img
src={steamUrlBuilder.logo(objectID!)} src={steamUrlBuilder.logo(objectID!)}
style={{ width: 300, alignSelf: "flex-end" }} className={styles.gameLogo}
alt={game?.title} alt={game?.title}
/> />
</div> </div>

View file

@ -1,6 +1,7 @@
import { globalStyle, keyframes, style } from "@vanilla-extract/css"; import { globalStyle, keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
import { recipe } from "@vanilla-extract/recipes";
export const HERO_HEIGHT = 300; export const HERO_HEIGHT = 300;
@ -9,12 +10,22 @@ export const slideIn = keyframes({
"100%": { transform: "translateY(0)" }, "100%": { transform: "translateY(0)" },
}); });
export const wrapper = style({ export const wrapper = recipe({
base: {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
overflow: "hidden", overflow: "hidden",
width: "100%", width: "100%",
height: "100%", height: "100%",
transition: "all ease 0.3s",
},
variants: {
blurredContent: {
true: {
filter: "blur(20px)",
},
},
},
}); });
export const hero = style({ 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({ export const heroImageSkeleton = style({
height: "300px", height: "300px",
"@media": { "@media": {

View file

@ -3,7 +3,7 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { GameRepack, GameShop, Steam250Game } from "@types"; import { GameRepack, GameShop, Steam250Game } from "@types";
import { Button } from "@renderer/components"; import { Button, ConfirmationModal } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
import starsAnimation from "@renderer/assets/lottie/stars.json"; import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -83,6 +83,8 @@ export function GameDetails() {
shop, shop,
showRepacksModal, showRepacksModal,
showGameOptionsModal, showGameOptionsModal,
hasNSFWContentBlocked,
setHasNSFWContentBlocked,
updateGame, updateGame,
setShowRepacksModal, setShowRepacksModal,
setShowGameOptionsModal, setShowGameOptionsModal,
@ -107,6 +109,10 @@ export function GameDetails() {
setShowGameOptionsModal(false); setShowGameOptionsModal(false);
}; };
const handleNSFWContentRefuse = () => {
navigate(-1);
};
return ( return (
<SkeletonTheme <SkeletonTheme
baseColor={vars.color.background} baseColor={vars.color.background}
@ -120,6 +126,19 @@ export function GameDetails() {
onClose={() => setShowRepacksModal(false)} 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 && ( {game && (
<GameOptionsModal <GameOptionsModal
visible={showGameOptionsModal} visible={showGameOptionsModal}

View file

@ -2,7 +2,7 @@ import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel.css"; import * as styles from "./hero-panel.css";
import { formatDownloadProgress } from "@renderer/helpers"; import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload } from "@renderer/hooks"; import { useDate, useDownload, useFormat } from "@renderer/hooks";
import { Link } from "@renderer/components"; import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
@ -13,7 +13,9 @@ export function HeroPanelPlaytime() {
const { game, isGameRunning } = useContext(gameDetailsContext); const { game, isGameRunning } = useContext(gameDetailsContext);
const { i18n, t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload(); const { progress, lastPacket } = useDownload();
@ -29,12 +31,6 @@ export function HeroPanelPlaytime() {
} }
}, [game?.lastTimePlayed, formatDistance]); }, [game?.lastTimePlayed, formatDistance]);
const numberFormatter = useMemo(() => {
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const formattedPlayTime = useMemo(() => { const formattedPlayTime = useMemo(() => {
const milliseconds = game?.playTimeInMilliseconds || 0; const milliseconds = game?.playTimeInMilliseconds || 0;
const seconds = milliseconds / 1000; const seconds = milliseconds / 1000;

View file

@ -70,7 +70,7 @@ export const howLongToBeatCategory = style({
flexDirection: "column", flexDirection: "column",
gap: "4px", gap: "4px",
backgroundColor: vars.color.background, backgroundColor: vars.color.background,
borderRadius: "8px", borderRadius: "4px",
padding: `8px 16px`, padding: `8px 16px`,
border: `solid 1px ${vars.color.border}`, border: `solid 1px ${vars.color.border}`,
}); });
@ -81,10 +81,32 @@ export const howLongToBeatCategoryLabel = style({
export const howLongToBeatCategorySkeleton = style({ export const howLongToBeatCategorySkeleton = style({
border: `solid 1px ${vars.color.border}`, border: `solid 1px ${vars.color.border}`,
borderRadius: "8px", borderRadius: "4px",
height: "76px", 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`, { globalStyle(`${requirementsDetails} a`, {
display: "flex", display: "flex",
color: vars.color.body, color: vars.color.body,

View file

@ -6,6 +6,8 @@ import { Button } from "@renderer/components";
import * as styles from "./sidebar.css"; import * as styles from "./sidebar.css";
import { gameDetailsContext } from "@renderer/context"; import { gameDetailsContext } from "@renderer/context";
import { useFormat } from "@renderer/hooks";
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
export function Sidebar() { export function Sidebar() {
const [howLongToBeat, setHowLongToBeat] = useState<{ const [howLongToBeat, setHowLongToBeat] = useState<{
@ -21,6 +23,8 @@ export function Sidebar() {
const { t } = useTranslation("game_details"); const { t } = useTranslation("game_details");
const { numberFormatter } = useFormat();
useEffect(() => { useEffect(() => {
if (objectID) { if (objectID) {
setHowLongToBeat({ isLoading: true, data: null }); setHowLongToBeat({ isLoading: true, data: null });
@ -43,18 +47,41 @@ export function Sidebar() {
isLoading={howLongToBeat.isLoading} isLoading={howLongToBeat.isLoading}
/> />
<div className={styles.contentSidebarTitle} style={{ border: "none" }}> {stats && (
<>
<div
className={styles.contentSidebarTitle}
style={{ border: "none" }}
>
<h3>{t("stats")}</h3> <h3>{t("stats")}</h3>
</div> </div>
<div> <div className={styles.statsSection}>
<p>downloadCount {stats?.downloadCount}</p> <div className={styles.statsCategory}>
<p>playerCount {stats?.playerCount}</p> <p className={styles.statsCategoryTitle}>
<DownloadIcon size={18} />
{t("download_count")}
</p>
<p>{numberFormatter.format(stats?.downloadCount)}</p>
</div> </div>
<div className={styles.contentSidebarTitle} style={{ border: "none" }}> <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> <h3>{t("requirements")}</h3>
</div> </div>
</>
)}
<div className={styles.requirementButtonContainer}> <div className={styles.requirementButtonContainer}>
<Button <Button

View file

@ -67,6 +67,12 @@ export function Home() {
} }
}; };
const handleCategoryClick = (category: CatalogueCategory) => {
if (category !== currentCatalogueCategory) {
getCatalogue(category);
}
};
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
getCatalogue(CatalogueCategory.Hot); getCatalogue(CatalogueCategory.Hot);
@ -93,7 +99,7 @@ export function Home() {
? "primary" ? "primary"
: "outline" : "outline"
} }
onClick={() => getCatalogue(category)} onClick={() => handleCategoryClick(category)}
> >
{t(category)} {t(category)}
</Button> </Button>

View file

@ -7,7 +7,7 @@ import type { DebouncedFunc } from "lodash";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { InboxIcon, SearchIcon } from "@primer/octicons-react"; import { InboxIcon, SearchIcon } from "@primer/octicons-react";
import { clearSearch } from "@renderer/features"; import { clearSearch, setSearch } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch } from "@renderer/hooks";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -37,6 +37,10 @@ export function SearchResults() {
navigate(buildGameDetailsPath(game)); navigate(buildGameDetailsPath(game));
}; };
useEffect(() => {
dispatch(setSearch(searchParams.get("query") ?? ""));
}, [dispatch, searchParams]);
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(true);
if (debouncedFunc.current) debouncedFunc.current.cancel(); if (debouncedFunc.current) debouncedFunc.current.cancel();

View file

@ -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",
});

View file

@ -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>
);
}

View file

@ -11,8 +11,8 @@ export function LockedProfile() {
<div className={styles.lockIcon}> <div className={styles.lockIcon}>
<LockIcon size={24} /> <LockIcon size={24} />
</div> </div>
<h2>{t("locked_profile")}</h2> <h2>{t("locked_profile")}</h2>
<p>{t("locked_profile_description")}</p>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useCallback, useContext, useEffect, useMemo } from "react"; import { useCallback, useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "../profile-hero/profile-hero"; import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css"; import { SPACING_UNIT } from "@renderer/theme.css";
@ -17,11 +17,11 @@ import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile"; import { LockedProfile } from "./locked-profile";
export function ProfileContent() { export function ProfileContent() {
const { userProfile } = useContext(userProfileContext); const { userProfile, isMe } = useContext(userProfileContext);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { i18n, t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
useEffect(() => { useEffect(() => {
if (userProfile) { if (userProfile) {
@ -34,11 +34,7 @@ export function ProfileContent() {
return userProfile?.libraryGames.slice(0, 12); return userProfile?.libraryGames.slice(0, 12);
}, [userProfile]); }, [userProfile]);
const numberFormatter = useMemo(() => { const { numberFormatter } = useFormat();
return new Intl.NumberFormat(i18n.language, {
maximumFractionDigits: 0,
});
}, [i18n.language]);
const navigate = useNavigate(); const navigate = useNavigate();
@ -65,10 +61,18 @@ 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;
if (userProfile?.profileVisibility === "FRIENDS") { const shouldLockProfile =
userProfile.profileVisibility === "PRIVATE" ||
(userProfile.profileVisibility === "FRIENDS" && !usersAreFriends);
if (!isMe && shouldLockProfile) {
return <LockedProfile />; return <LockedProfile />;
} }
@ -213,6 +217,8 @@ export function ProfileContent() {
numberFormatter, numberFormatter,
t, t,
truncatedGamesList, truncatedGamesList,
usersAreFriends,
isMe,
navigate, navigate,
]); ]);

View file

@ -20,6 +20,7 @@ export const profileAvatarButton = style({
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)", boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
cursor: "pointer", cursor: "pointer",
transition: "all ease 0.3s", transition: "all ease 0.3s",
color: vars.color.muted,
":hover": { ":hover": {
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.7)", boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.7)",
}, },
@ -69,3 +70,16 @@ export const userInformation = style({
alignItems: "center", alignItems: "center",
gap: `${SPACING_UNIT * 2}px`, 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",
});

View file

@ -18,7 +18,7 @@ import { addSeconds } from "date-fns";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { FriendRequestAction } from "@types"; import type { FriendRequestAction } from "@types";
import { UserProfileSettingsModal } from "../user-profile-settings-modal"; import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
type FriendAction = type FriendAction =
| FriendRequestAction | FriendRequestAction
@ -26,6 +26,7 @@ type FriendAction =
export function ProfileHero() { export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const context = useContext(userProfileContext); const context = useContext(userProfileContext);
const { const {
@ -49,14 +50,22 @@ export function ProfileHero() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleSignOut = useCallback(async () => { const handleSignOut = useCallback(async () => {
setIsPerformingAction(true);
try {
await signOut(); await signOut();
showSuccessToast(t("successfully_signed_out")); showSuccessToast(t("successfully_signed_out"));
} finally {
setIsPerformingAction(false);
}
navigate("/"); navigate("/");
}, [navigate, signOut, showSuccessToast, t]); }, [navigate, signOut, showSuccessToast, t]);
const handleFriendAction = useCallback( const handleFriendAction = useCallback(
(userId: string, action: FriendAction) => { (userId: string, action: FriendAction) => {
setIsPerformingAction(true);
try { try {
if (action === "UNDO_FRIENDSHIP") { if (action === "UNDO_FRIENDSHIP") {
undoFriendship(userId).then(getUserProfile); undoFriendship(userId).then(getUserProfile);
@ -80,6 +89,8 @@ export function ProfileHero() {
updateFriendRequestState(userId, action).then(getUserProfile); updateFriendRequestState(userId, action).then(getUserProfile);
} catch (err) { } catch (err) {
showErrorToast(t("try_again")); showErrorToast(t("try_again"));
} finally {
setIsPerformingAction(false);
} }
}, },
[ [
@ -100,12 +111,20 @@ export function ProfileHero() {
if (isMe) { if (isMe) {
return ( return (
<> <>
<Button theme="outline" onClick={() => setShowEditProfileModal(true)}> <Button
theme="outline"
onClick={() => setShowEditProfileModal(true)}
disabled={isPerformingAction}
>
<PencilIcon /> <PencilIcon />
{t("edit_profile")} {t("edit_profile")}
</Button> </Button>
<Button theme="danger" onClick={handleSignOut}> <Button
theme="danger"
onClick={handleSignOut}
disabled={isPerformingAction}
>
<SignOutIcon /> <SignOutIcon />
{t("sign_out")} {t("sign_out")}
</Button> </Button>
@ -119,6 +138,7 @@ export function ProfileHero() {
<Button <Button
theme="outline" theme="outline"
onClick={() => handleFriendAction(userProfile.id, "SEND")} onClick={() => handleFriendAction(userProfile.id, "SEND")}
disabled={isPerformingAction}
> >
{t("add_friend")} {t("add_friend")}
</Button> </Button>
@ -126,6 +146,7 @@ export function ProfileHero() {
<Button <Button
theme="danger" theme="danger"
onClick={() => handleFriendAction(userProfile.id, "BLOCK")} onClick={() => handleFriendAction(userProfile.id, "BLOCK")}
disabled={isPerformingAction}
> >
{t("block_user")} {t("block_user")}
</Button> </Button>
@ -138,6 +159,7 @@ export function ProfileHero() {
<Button <Button
theme="outline" theme="outline"
onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")} onClick={() => handleFriendAction(userProfile.id, "UNDO_FRIENDSHIP")}
disabled={isPerformingAction}
> >
<XCircleFillIcon /> <XCircleFillIcon />
{t("undo_friendship")} {t("undo_friendship")}
@ -152,6 +174,7 @@ export function ProfileHero() {
onClick={() => onClick={() =>
handleFriendAction(userProfile.relation!.BId, "CANCEL") handleFriendAction(userProfile.relation!.BId, "CANCEL")
} }
disabled={isPerformingAction}
> >
<XCircleFillIcon /> {t("cancel_request")} <XCircleFillIcon /> {t("cancel_request")}
</Button> </Button>
@ -165,6 +188,7 @@ export function ProfileHero() {
onClick={() => onClick={() =>
handleFriendAction(userProfile.relation!.AId, "ACCEPTED") handleFriendAction(userProfile.relation!.AId, "ACCEPTED")
} }
disabled={isPerformingAction}
> >
<CheckCircleFillIcon /> {t("accept_request")} <CheckCircleFillIcon /> {t("accept_request")}
</Button> </Button>
@ -173,12 +197,20 @@ export function ProfileHero() {
onClick={() => onClick={() =>
handleFriendAction(userProfile.relation!.AId, "REFUSED") handleFriendAction(userProfile.relation!.AId, "REFUSED")
} }
disabled={isPerformingAction}
> >
<XCircleFillIcon /> {t("ignore_request")} <XCircleFillIcon /> {t("ignore_request")}
</Button> </Button>
</> </>
); );
}, [handleFriendAction, handleSignOut, isMe, t, userProfile]); }, [
handleFriendAction,
handleSignOut,
isMe,
t,
isPerformingAction,
userProfile,
]);
const handleAvatarClick = useCallback(() => { const handleAvatarClick = useCallback(() => {
if (isMe) { if (isMe) {
@ -196,10 +228,8 @@ export function ProfileHero() {
cancelButtonLabel={t("cancel")} cancelButtonLabel={t("cancel")}
/> */} /> */}
<UserProfileSettingsModal <EditProfileModal
visible={showEditProfileModal} visible={showEditProfileModal}
userProfile={userProfile}
updateUserProfile={getUserProfile}
onClose={() => setShowEditProfileModal(false)} onClose={() => setShowEditProfileModal(false)}
/> />
@ -230,21 +260,8 @@ export function ProfileHero() {
</h2> </h2>
{currentGame && ( {currentGame && (
<div <div className={styles.currentGameWrapper}>
style={{ <div className={styles.currentGameDetails}>
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT}px`,
alignItems: "center",
}}
>
<Link <Link
to={buildGameDetailsPath({ to={buildGameDetailsPath({
...currentGame, ...currentGame,

View file

@ -1,5 +1,5 @@
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT } from "../../theme.css";
import { globalStyle, style } from "@vanilla-extract/css"; import { style } from "@vanilla-extract/css";
export const wrapper = style({ export const wrapper = style({
width: "100%", width: "100%",
@ -7,47 +7,3 @@ export const wrapper = style({
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`, 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",
});

View file

@ -1 +0,0 @@
export * from "./user-profile-settings-modal";

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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>
</>
);
};

View 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`,
});

View 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>
);
}

View file

@ -11,6 +11,7 @@ import {
SettingsContextConsumer, SettingsContextConsumer,
SettingsContextProvider, SettingsContextProvider,
} from "@renderer/context"; } from "@renderer/context";
import { SettingsPrivacy } from "./settings-privacy";
export function Settings() { export function Settings() {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");
@ -20,6 +21,7 @@ export function Settings() {
t("behavior"), t("behavior"),
t("download_sources"), t("download_sources"),
"Real-Debrid", "Real-Debrid",
t("privacy"),
]; ];
return ( return (
@ -39,7 +41,11 @@ export function Settings() {
return <SettingsDownloadSources />; return <SettingsDownloadSources />;
} }
if (currentCategoryIndex === 3) {
return <SettingsRealDebrid />; return <SettingsRealDebrid />;
}
return <SettingsPrivacy />;
}; };
return ( return (

25
src/shared/constants.ts Normal file
View file

@ -0,0 +1,25 @@
export enum Downloader {
RealDebrid,
Torrent,
Gofile,
PixelDrain,
Qiwi,
}
export enum DownloadSourceStatus {
UpToDate,
Errored,
}
export enum CatalogueCategory {
Hot = "hot",
Weekly = "weekly",
}
export enum SteamContentDescriptor {
SomeNudityOrSexualContent = 1,
FrequenceViolenceOrGore = 2,
AdultOnlySexualContent = 3,
FrequentNudityOrSexualContent = 4,
GeneralMatureContent = 5,
}

View file

@ -1,20 +1,6 @@
export enum Downloader { import { Downloader } from "./constants";
RealDebrid,
Torrent,
Gofile,
PixelDrain,
Qiwi,
}
export enum DownloadSourceStatus { export * from "./constants";
UpToDate,
Errored,
}
export enum CatalogueCategory {
Hot = "hot",
Weekly = "weekly",
}
export class UserNotLoggedInError extends Error { export class UserNotLoggedInError extends Error {
constructor() { constructor() {

View file

@ -1,4 +1,5 @@
import type { DownloadSourceStatus, Downloader } from "@shared"; import type { DownloadSourceStatus, Downloader } from "@shared";
import type { SteamAppDetails } from "./steam.types";
export type GameStatus = export type GameStatus =
| "active" | "active"
@ -12,58 +13,6 @@ export type GameShop = "steam" | "epic";
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL"; export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface SteamGenre {
id: string;
name: string;
}
export interface SteamScreenshot {
id: number;
path_thumbnail: string;
path_full: string;
}
export interface SteamVideoSource {
max: string;
"480": string;
}
export interface SteamMovies {
id: number;
mp4: SteamVideoSource;
webm: SteamVideoSource;
thumbnail: string;
name: string;
highlight: boolean;
}
export interface SteamAppDetails {
name: string;
detailed_description: string;
about_the_game: string;
short_description: string;
publishers: string[];
genres: SteamGenre[];
movies?: SteamMovies[];
screenshots?: SteamScreenshot[];
pc_requirements: {
minimum: string;
recommended: string;
};
mac_requirements: {
minimum: string;
recommended: string;
};
linux_requirements: {
minimum: string;
recommended: string;
};
release_date: {
coming_soon: boolean;
date: string;
};
}
export interface GameRepack { export interface GameRepack {
id: number; id: number;
title: string; title: string;
@ -203,73 +152,6 @@ export interface StartGameDownloadPayload {
downloader: Downloader; downloader: Downloader;
} }
export interface RealDebridUnrestrictLink {
id: string;
filename: string;
mimeType: string;
filesize: number;
link: string;
host: string;
host_icon: string;
chunks: number;
crc: number;
download: string;
streamable: number;
}
export interface RealDebridAddMagnet {
id: string;
// URL of the created resource
uri: string;
}
export interface RealDebridTorrentInfo {
id: string;
filename: string;
original_filename: string;
hash: string;
bytes: number;
original_bytes: number;
host: string;
split: number;
progress: number;
status:
| "magnet_error"
| "magnet_conversion"
| "waiting_files_selection"
| "queued"
| "downloading"
| "downloaded"
| "error"
| "virus"
| "compressing"
| "uploading"
| "dead";
added: string;
files: {
id: number;
path: string;
bytes: number;
selected: number;
}[];
links: string[];
ended: string;
speed: number;
seeders: number;
}
export interface RealDebridUser {
id: number;
username: string;
email: string;
points: number;
locale: string;
avatar: string;
type: string;
premium: number;
expiration: string;
}
export interface UserFriend { export interface UserFriend {
id: string; id: string;
displayName: string; displayName: string;
@ -351,3 +233,6 @@ export interface TrendingGame {
description: string; description: string;
background: string; background: string;
} }
export * from "./steam.types";
export * from "./real-debrid.types";

View file

@ -0,0 +1,66 @@
export interface RealDebridUnrestrictLink {
id: string;
filename: string;
mimeType: string;
filesize: number;
link: string;
host: string;
host_icon: string;
chunks: number;
crc: number;
download: string;
streamable: number;
}
export interface RealDebridAddMagnet {
id: string;
// URL of the created resource
uri: string;
}
export interface RealDebridTorrentInfo {
id: string;
filename: string;
original_filename: string;
hash: string;
bytes: number;
original_bytes: number;
host: string;
split: number;
progress: number;
status:
| "magnet_error"
| "magnet_conversion"
| "waiting_files_selection"
| "queued"
| "downloading"
| "downloaded"
| "error"
| "virus"
| "compressing"
| "uploading"
| "dead";
added: string;
files: {
id: number;
path: string;
bytes: number;
selected: number;
}[];
links: string[];
ended: string;
speed: number;
seeders: number;
}
export interface RealDebridUser {
id: number;
username: string;
email: string;
points: number;
locale: string;
avatar: string;
type: string;
premium: number;
expiration: string;
}

54
src/types/steam.types.ts Normal file
View file

@ -0,0 +1,54 @@
export interface SteamGenre {
id: string;
name: string;
}
export interface SteamScreenshot {
id: number;
path_thumbnail: string;
path_full: string;
}
export interface SteamVideoSource {
max: string;
"480": string;
}
export interface SteamMovies {
id: number;
mp4: SteamVideoSource;
webm: SteamVideoSource;
thumbnail: string;
name: string;
highlight: boolean;
}
export interface SteamAppDetails {
name: string;
detailed_description: string;
about_the_game: string;
short_description: string;
publishers: string[];
genres: SteamGenre[];
movies?: SteamMovies[];
screenshots?: SteamScreenshot[];
pc_requirements: {
minimum: string;
recommended: string;
};
mac_requirements: {
minimum: string;
recommended: string;
};
linux_requirements: {
minimum: string;
recommended: string;
};
release_date: {
coming_soon: boolean;
date: string;
};
content_descriptors: {
ids: number[];
};
}

View file

@ -1,6 +1,6 @@
{ {
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json", "extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/index.ts"], "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/locales/index.ts", "src/shared/**/*", "src/types/**/*"],
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
"composite": true, "composite": true,

View file

@ -6,7 +6,7 @@
"src/renderer/src/**/*.tsx", "src/renderer/src/**/*.tsx",
"src/preload/*.d.ts", "src/preload/*.d.ts",
"src/locales/index.ts", "src/locales/index.ts",
"src/shared/index.ts" "src/shared/**/*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,

View file

@ -955,6 +955,11 @@
resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.0.22.tgz#2c5249347ba84fef16e71a58e0ec01b460174093" resolved "https://registry.yarnpkg.com/@fontsource/noto-sans/-/noto-sans-5.0.22.tgz#2c5249347ba84fef16e71a58e0ec01b460174093"
integrity sha512-PwjvKPGFbgpwfKjWZj1zeUvd7ExUW2AqHE9PF9ysAJ2gOuzIHWE6mEVIlchYif7WC2pQhn+g0w6xooCObVi+4A== integrity sha512-PwjvKPGFbgpwfKjWZj1zeUvd7ExUW2AqHE9PF9ysAJ2gOuzIHWE6mEVIlchYif7WC2pQhn+g0w6xooCObVi+4A==
"@hookform/resolvers@^3.9.0":
version "3.9.0"
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-3.9.0.tgz#cf540ac21c6c0cd24a40cf53d8e6d64391fb753d"
integrity sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==
"@humanwhocodes/config-array@^0.11.14": "@humanwhocodes/config-array@^0.11.14":
version "0.11.14" version "0.11.14"
resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz" resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz"
@ -2879,10 +2884,10 @@ axe-core@=4.7.0:
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz" resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz"
integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==
axios@^1.6.8: axios@^1.7.7:
version "1.6.8" version "1.7.7"
resolved "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f"
integrity sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ== integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==
dependencies: dependencies:
follow-redirects "^1.15.6" follow-redirects "^1.15.6"
form-data "^4.0.0" form-data "^4.0.0"
@ -6430,6 +6435,11 @@ prop-types@^15.8.1:
object-assign "^4.1.1" object-assign "^4.1.1"
react-is "^16.13.1" react-is "^16.13.1"
property-expr@^2.0.5:
version "2.0.6"
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
proxy-from-env@^1.1.0: proxy-from-env@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz" resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz"
@ -6486,6 +6496,11 @@ react-dom@^18.2.0:
loose-envify "^1.1.0" loose-envify "^1.1.0"
scheduler "^0.23.2" scheduler "^0.23.2"
react-hook-form@^7.53.0:
version "7.53.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.53.0.tgz#3cf70951bf41fa95207b34486203ebefbd3a05ab"
integrity sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==
react-i18next@^14.1.0: react-i18next@^14.1.0:
version "14.1.1" version "14.1.1"
resolved "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz" resolved "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz"
@ -7254,6 +7269,11 @@ tildify@2.0.0:
resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a" resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a"
integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw== integrity sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==
tiny-case@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-case/-/tiny-case-1.0.3.tgz#d980d66bc72b5d5a9ca86fb7c9ffdb9c898ddd03"
integrity sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==
tiny-typed-emitter@^2.1.0: tiny-typed-emitter@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz" resolved "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz"
@ -7301,6 +7321,11 @@ token-types@^5.0.1:
"@tokenizer/token" "^0.3.0" "@tokenizer/token" "^0.3.0"
ieee754 "^1.2.1" ieee754 "^1.2.1"
toposort@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330"
integrity sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==
tough-cookie@^4.0.0, tough-cookie@^4.1.3: tough-cookie@^4.0.0, tough-cookie@^4.1.3:
version "4.1.4" version "4.1.4"
resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz"
@ -7383,6 +7408,11 @@ type-fest@^0.20.2:
resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
type-fest@^2.19.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
typed-array-buffer@^1.0.2: typed-array-buffer@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz" resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz"
@ -7878,6 +7908,16 @@ yocto-queue@^1.0.0:
resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz"
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
yup@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/yup/-/yup-1.4.0.tgz#898dcd660f9fb97c41f181839d3d65c3ee15a43e"
integrity sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==
dependencies:
property-expr "^2.0.5"
tiny-case "^1.0.3"
toposort "^2.0.2"
type-fest "^2.19.0"
zod@^3.23.8: zod@^3.23.8:
version "3.23.8" version "3.23.8"
resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz" resolved "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz"