merge branch 'main' of https://github.com/KelvinDiasMoreira/hydra into feature/delete-all-dowload-sources

This commit is contained in:
Kelvin
2025-03-11 22:01:28 -03:00
139 changed files with 3095 additions and 663 deletions

View File

@@ -28,6 +28,7 @@ import { downloadSourcesTable } from "./dexie";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss } from "./helpers";
import "./app.scss";
export interface AppProps {
@@ -233,6 +234,17 @@ export function App() {
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}, [updateRepacks]);
useEffect(() => {
const loadAndApplyTheme = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {
injectCustomCss(activeTheme.code);
}
};
loadAndApplyTheme();
}, []);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
@@ -249,6 +261,14 @@ export function App() {
};
}, [playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected((cssString) => {
injectCustomCss(cssString);
});
return () => unsubscribe();
}, []);
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);

View File

@@ -10,6 +10,7 @@
cursor: pointer;
color: globals.$muted-color;
position: relative;
overflow: hidden;
&__image {
height: 100%;

View File

@@ -1,4 +1,5 @@
import { PersonIcon } from "@primer/octicons-react";
import cn from "classnames";
import "./avatar.scss";
@@ -14,11 +15,18 @@ export interface AvatarProps
src?: string | null;
}
export function Avatar({ size, alt, src, ...props }: AvatarProps) {
export function Avatar({ size, alt, src, className, ...props }: AvatarProps) {
return (
<div className="profile-avatar" style={{ width: size, height: size }}>
{src ? (
<img className="profile-avatar__image" alt={alt} src={src} {...props} />
<img
className={cn("profile-avatar__image", className)}
alt={alt}
src={src}
width={size}
height={size}
{...props}
/>
) : (
<PersonIcon size={size * 0.7} />
)}

View File

@@ -6,7 +6,10 @@ export interface BackdropProps {
children: React.ReactNode;
}
export function Backdrop({ isClosing = false, children }: BackdropProps) {
export function Backdrop({
isClosing = false,
children,
}: Readonly<BackdropProps>) {
return (
<div
className={cn("backdrop", {

View File

@@ -15,7 +15,7 @@ export function Button({
theme = "primary",
className,
...props
}: ButtonProps) {
}: Readonly<ButtonProps>) {
return (
<button
type="button"

View File

@@ -7,7 +7,7 @@ export interface CheckboxFieldProps
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label: string;
label: string | React.ReactNode;
}
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {

View File

@@ -0,0 +1,50 @@
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { LibraryGame } from "@types";
import cn from "classnames";
import { useLocation } from "react-router-dom";
interface SidebarGameItemProps {
game: LibraryGame;
handleSidebarGameClick: (event: React.MouseEvent, game: LibraryGame) => void;
getGameTitle: (game: LibraryGame) => string;
}
export function SidebarGameItem({
game,
handleSidebarGameClick,
getGameTitle,
}: Readonly<SidebarGameItemProps>) {
const location = useLocation();
return (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted": game.download?.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
);
}

View File

@@ -23,6 +23,7 @@
&__content {
display: flex;
flex-direction: column;
flex: 1;
padding: calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit * 2);
width: 100%;
@@ -54,6 +55,7 @@
display: flex;
color: globals.$muted-color;
border-radius: 4px;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
@@ -104,13 +106,14 @@
&__container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
&__section {
gap: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
padding-bottom: globals.$spacing-unit;
}

View File

@@ -18,11 +18,11 @@ import "./sidebar.scss";
import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react";
import { SidebarGameItem } from "./sidebar-game-item";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -167,6 +167,10 @@ export function Sidebar() {
}
};
const favoriteGames = useMemo(() => {
return sortedLibrary.filter((game) => game.favorite);
}, [sortedLibrary]);
return (
<aside
ref={sidebarRef}
@@ -206,6 +210,23 @@ export function Sidebar() {
</ul>
</section>
{favoriteGames.length > 0 && (
<section className="sidebar__section">
<small className="sidebar__section-title">{t("favorites")}</small>
<ul className="sidebar__menu">
{favoriteGames.map((game) => (
<SidebarGameItem
key={game.id}
game={game}
handleSidebarGameClick={handleSidebarGameClick}
getGameTitle={getGameTitle}
/>
))}
</ul>
</section>
)}
<section className="sidebar__section">
<small className="sidebar__section-title">{t("my_library")}</small>
@@ -217,39 +238,16 @@ export function Sidebar() {
/>
<ul className="sidebar__menu">
{filteredLibrary.map((game) => (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname ===
`/game/${game.shop}/${game.objectId}`,
"sidebar__menu-item--muted":
game.download?.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
))}
{filteredLibrary
.filter((game) => !game.favorite)
.map((game) => (
<SidebarGameItem
key={game.id}
game={game}
handleSidebarGameClick={handleSidebarGameClick}
getGameTitle={getGameTitle}
/>
))}
</ul>
</section>
</div>

View File

@@ -7,8 +7,9 @@
background-color: globals.$dark-background-color;
border-radius: 4px;
border: solid 1px globals.$border-color;
right: 16px;
bottom: 26px + globals.$spacing-unit;
right: calc(globals.$spacing-unit * 2);
// 28px is the height of the bottom panel
bottom: calc(28px + globals.$spacing-unit * 2);
overflow: hidden;
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,6 @@
import { Downloader } from "@shared";
export const VERSION_CODENAME = "Spectre";
export const VERSION_CODENAME = "Polychrome";
export const DOWNLOADER_NAME = {
[Downloader.RealDebrid]: "Real-Debrid",
@@ -14,3 +14,5 @@ export const DOWNLOADER_NAME = {
};
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
export const THEME_WEB_STORE_URL = "https://hydrathemes.shop";

View File

@@ -9,20 +9,32 @@ export interface SettingsContext {
updateUserPreferences: (values: Partial<UserPreferences>) => Promise<void>;
setCurrentCategoryIndex: React.Dispatch<React.SetStateAction<number>>;
clearSourceUrl: () => void;
clearTheme: () => void;
sourceUrl: string | null;
currentCategoryIndex: number;
blockedUsers: UserBlocks["blocks"];
fetchBlockedUsers: () => Promise<void>;
appearance: {
theme: string | null;
authorId: string | null;
authorName: string | null;
};
}
export const settingsContext = createContext<SettingsContext>({
updateUserPreferences: async () => {},
setCurrentCategoryIndex: () => {},
clearSourceUrl: () => {},
clearTheme: () => {},
sourceUrl: null,
currentCategoryIndex: 0,
blockedUsers: [],
fetchBlockedUsers: async () => {},
appearance: {
theme: null,
authorId: null,
authorName: null,
},
});
const { Provider } = settingsContext;
@@ -34,15 +46,26 @@ export interface SettingsContextProviderProps {
export function SettingsContextProvider({
children,
}: SettingsContextProviderProps) {
}: Readonly<SettingsContextProviderProps>) {
const dispatch = useAppDispatch();
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
const [appearance, setAppearance] = useState<{
theme: string | null;
authorId: string | null;
authorName: string | null;
}>({
theme: null,
authorId: null,
authorName: null,
});
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
const [blockedUsers, setBlockedUsers] = useState<UserBlocks["blocks"]>([]);
const [searchParams] = useSearchParams();
const defaultSourceUrl = searchParams.get("urls");
const defaultAppearanceTheme = searchParams.get("theme");
const defaultAppearanceAuthorId = searchParams.get("authorId");
const defaultAppearanceAuthorName = searchParams.get("authorName");
useEffect(() => {
if (sourceUrl) setCurrentCategoryIndex(2);
@@ -54,6 +77,36 @@ export function SettingsContextProvider({
}
}, [defaultSourceUrl]);
useEffect(() => {
if (appearance.theme) setCurrentCategoryIndex(3);
}, [appearance.theme]);
useEffect(() => {
if (
defaultAppearanceTheme &&
defaultAppearanceAuthorId &&
defaultAppearanceAuthorName
) {
setAppearance({
theme: defaultAppearanceTheme,
authorId: defaultAppearanceAuthorId,
authorName: defaultAppearanceAuthorName,
});
}
}, [
defaultAppearanceTheme,
defaultAppearanceAuthorId,
defaultAppearanceAuthorName,
]);
const clearTheme = useCallback(() => {
setAppearance({
theme: null,
authorId: null,
authorName: null,
});
}, []);
const fetchBlockedUsers = useCallback(async () => {
const blockedUsers = await window.electron.getBlockedUsers(12, 0);
setBlockedUsers(blockedUsers.blocks);
@@ -79,9 +132,11 @@ export function SettingsContextProvider({
setCurrentCategoryIndex,
clearSourceUrl,
fetchBlockedUsers,
clearTheme,
currentCategoryIndex,
sourceUrl,
blockedUsers,
appearance,
}}
>
{children}

View File

@@ -1,6 +1,6 @@
import { darkenColor } from "@renderer/helpers";
import { useAppSelector, useToast } from "@renderer/hooks";
import type { UserProfile, UserStats } from "@types";
import type { Badge, UserProfile, UserStats } from "@types";
import { average } from "color.js";
import { createContext, useCallback, useEffect, useState } from "react";
@@ -16,6 +16,7 @@ export interface UserProfileContext {
getUserProfile: () => Promise<void>;
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
backgroundImage: string;
badges: Badge[];
}
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
@@ -28,6 +29,7 @@ export const userProfileContext = createContext<UserProfileContext>({
getUserProfile: async () => {},
setSelectedBackgroundImage: () => {},
backgroundImage: "",
badges: [],
});
const { Provider } = userProfileContext;
@@ -41,12 +43,13 @@ export interface UserProfileContextProviderProps {
export function UserProfileContextProvider({
children,
userId,
}: UserProfileContextProviderProps) {
}: Readonly<UserProfileContextProviderProps>) {
const { userDetails } = useAppSelector((state) => state.userDetails);
const [userStats, setUserStats] = useState<UserStats | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [badges, setBadges] = useState<Badge[]>([]);
const [heroBackground, setHeroBackground] = useState(
DEFAULT_USER_PROFILE_BACKGROUND
);
@@ -101,12 +104,18 @@ export function UserProfileContextProvider({
});
}, [navigate, getUserStats, showErrorToast, userId, t]);
const getBadges = useCallback(async () => {
const badges = await window.electron.getBadges();
setBadges(badges);
}, []);
useEffect(() => {
setUserProfile(null);
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
getUserProfile();
}, [getUserProfile]);
getBadges();
}, [getUserProfile, getBadges]);
return (
<Provider
@@ -118,6 +127,7 @@ export function UserProfileContextProvider({
setSelectedBackgroundImage,
backgroundImage: getBackgroundImageUrl(),
userStats,
badges,
}}
>
{children}

View File

@@ -29,6 +29,9 @@ import type {
LibraryGame,
GameRunning,
TorBoxUser,
Theme,
Badge,
Auth,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type disk from "diskusage";
@@ -85,6 +88,11 @@ declare global {
getDevelopers: () => Promise<string[]>;
/* Library */
toggleAutomaticCloudSync: (
shop: GameShop,
objectId: string,
automaticCloudSync: boolean
) => Promise<void>;
addGameToLibrary: (
shop: GameShop,
objectId: string,
@@ -96,6 +104,11 @@ declare global {
objectId: string,
executablePath: string | null
) => Promise<void>;
addGameToFavorites: (shop: GameShop, objectId: string) => Promise<void>;
removeGameFromFavorites: (
shop: GameShop,
objectId: string
) => Promise<void>;
updateLaunchOptions: (
shop: GameShop,
objectId: string,
@@ -211,6 +224,7 @@ declare global {
) => Promise<Electron.OpenDialogReturnValue>;
showItemInFolder: (path: string) => Promise<void>;
getFeatures: () => Promise<string[]>;
getBadges: () => Promise<Badge[]>;
platform: NodeJS.Platform;
/* Auto update */
@@ -221,6 +235,7 @@ declare global {
restartAndInstallUpdate: () => Promise<void>;
/* Auth */
getAuth: () => Promise<Auth | null>;
signOut: () => Promise<void>;
openAuthWindow: (page: AuthPage) => Promise<void>;
getSessionHash: () => Promise<string | null>;
@@ -274,6 +289,23 @@ declare global {
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
/* Themes */
addCustomTheme: (theme: Theme) => Promise<void>;
getAllCustomThemes: () => Promise<Theme[]>;
deleteAllCustomThemes: () => Promise<void>;
deleteCustomTheme: (themeId: string) => Promise<void>;
updateCustomTheme: (themeId: string, code: string) => Promise<void>;
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
/* Editor */
openEditorWindow: (themeId: string) => Promise<void>;
onCssInjected: (
cb: (cssString: string) => void
) => () => Electron.IpcRenderer;
closeEditorWindow: (themeId?: string) => Promise<void>;
}
interface Window {

View File

@@ -26,7 +26,7 @@ export const toastSlice = createSlice({
state.title = action.payload.title;
state.message = action.payload.message;
state.type = action.payload.type;
state.duration = action.payload.duration ?? 5000;
state.duration = action.payload.duration ?? 2000;
state.visible = true;
},
closeToast: (state) => {

View File

@@ -1,6 +1,7 @@
import type { GameShop } from "@types";
import Color from "color";
import { THEME_WEB_STORE_URL } from "./constants";
export const formatDownloadProgress = (
progress?: number,
@@ -53,3 +54,36 @@ export const buildGameAchievementPath = (
export const darkenColor = (color: string, amount: number, alpha: number = 1) =>
new Color(color).darken(amount).alpha(alpha).toString();
export const injectCustomCss = (css: string) => {
try {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
if (css.startsWith(THEME_WEB_STORE_URL)) {
const link = document.createElement("link");
link.id = "custom-css";
link.rel = "stylesheet";
link.href = css;
document.head.appendChild(link);
} else {
const style = document.createElement("style");
style.id = "custom-css";
style.textContent = `
${css}
`;
document.head.appendChild(style);
}
} catch (error) {
console.error("failed to inject custom css:", error);
}
};
export const removeCustomCss = () => {
const currentCustomCss = document.getElementById("custom-css");
if (currentCustomCss) {
currentCustomCss.remove();
}
};

View File

@@ -39,7 +39,7 @@ export function useDownload() {
const pauseDownload = async (shop: GameShop, objectId: string) => {
await window.electron.pauseGameDownload(shop, objectId);
await updateLibrary();
dispatch(clearDownload());
if (lastPacket?.gameId === `${shop}:${objectId}`) dispatch(clearDownload());
};
const resumeDownload = async (shop: GameShop, objectId: string) => {

View File

@@ -1,18 +1,26 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
enum Feature {
CheckDownloadWritePermission = "CHECK_DOWNLOAD_WRITE_PERMISSION",
Torbox = "TORBOX",
}
export function useFeature() {
const [features, setFeatures] = useState<string[] | null>(null);
useEffect(() => {
window.electron.getFeatures().then((features) => {
localStorage.setItem("features", JSON.stringify(features || []));
setFeatures(features || []);
});
}, []);
const isFeatureEnabled = (feature: Feature) => {
const features = JSON.parse(localStorage.getItem("features") || "[]");
if (!features) {
const features = JSON.parse(localStorage.getItem("features") ?? "[]");
return features.includes(feature);
}
return features.includes(feature);
};

View File

@@ -11,6 +11,7 @@ import "@fontsource/noto-sans/500.css";
import "@fontsource/noto-sans/700.css";
import "react-loading-skeleton/dist/skeleton.css";
import "react-tooltip/dist/react-tooltip.css";
import { App } from "./app";
@@ -18,23 +19,17 @@ import { store } from "./store";
import resources from "@locales";
import { SuspenseWrapper } from "./components";
import { logger } from "./logger";
import { addCookieInterceptor } from "./cookies";
const Home = React.lazy(() => import("./pages/home/home"));
const GameDetails = React.lazy(
() => import("./pages/game-details/game-details")
);
const Downloads = React.lazy(() => import("./pages/downloads/downloads"));
const Settings = React.lazy(() => import("./pages/settings/settings"));
const Catalogue = React.lazy(() => import("./pages/catalogue/catalogue"));
const Profile = React.lazy(() => import("./pages/profile/profile"));
const Achievements = React.lazy(
() => import("./pages/achievements/achievements")
);
import * as Sentry from "@sentry/react";
import Catalogue from "./pages/catalogue/catalogue";
import Home from "./pages/home/home";
import Downloads from "./pages/downloads/downloads";
import GameDetails from "./pages/game-details/game-details";
import Settings from "./pages/settings/settings";
import Profile from "./pages/profile/profile";
import Achievements from "./pages/achievements/achievements";
import ThemeEditor from "./pages/theme-editor/theme-editor";
Sentry.init({
dsn: import.meta.env.RENDERER_VITE_SENTRY_DSN,
@@ -79,32 +74,16 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<HashRouter>
<Routes>
<Route element={<App />}>
<Route path="/" element={<SuspenseWrapper Component={Home} />} />
<Route
path="/catalogue"
element={<SuspenseWrapper Component={Catalogue} />}
/>
<Route
path="/downloads"
element={<SuspenseWrapper Component={Downloads} />}
/>
<Route
path="/game/:shop/:objectId"
element={<SuspenseWrapper Component={GameDetails} />}
/>
<Route
path="/settings"
element={<SuspenseWrapper Component={Settings} />}
/>
<Route
path="/profile/:userId"
element={<SuspenseWrapper Component={Profile} />}
/>
<Route
path="/achievements"
element={<SuspenseWrapper Component={Achievements} />}
/>
<Route path="/" element={<Home />} />
<Route path="/catalogue" element={<Catalogue />} />
<Route path="/downloads" element={<Downloads />} />
<Route path="/game/:shop/:objectId" element={<GameDetails />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile/:userId" element={<Profile />} />
<Route path="/achievements" element={<Achievements />} />
</Route>
<Route path="/theme-editor" element={<ThemeEditor />} />
</Routes>
</HashRouter>
</Provider>

View File

@@ -31,7 +31,7 @@ $logo-max-width: 200px;
display: flex;
justify-content: center;
width: 100%;
gap: globals.$spacing-unit / 2;
gap: calc(globals.$spacing-unit / 2);
color: globals.$body-color;
cursor: pointer;

View File

@@ -12,7 +12,7 @@ export function DeleteGameModal({
onClose,
visible,
deleteGame,
}: DeleteGameModalProps) {
}: Readonly<DeleteGameModalProps>) {
const { t } = useTranslation("downloads");
const handleDeleteGame = () => {

View File

@@ -5,6 +5,14 @@
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
&__details-with-article {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
align-self: flex-start;
cursor: pointer;
}
&__header {
display: flex;
align-items: center;

View File

@@ -24,6 +24,7 @@ import {
DownloadIcon,
LinkIcon,
PlayIcon,
QuestionIcon,
ThreeBarsIcon,
TrashIcon,
UnlinkIcon,
@@ -31,6 +32,7 @@ import {
} from "@primer/octicons-react";
import torBoxLogo from "@renderer/assets/icons/torbox.webp";
export interface DownloadGroupProps {
library: LibraryGame[];
title: string;
@@ -122,8 +124,12 @@ export function DownloadGroup({
</p>
{download.downloader === Downloader.Torrent && (
<small>
<small
className="download-group__details-with-article"
data-open-article="peers-and-seeds"
>
{lastPacket?.numPeers} peers / {lastPacket?.numSeeds} seeds
<QuestionIcon size={12} />
</small>
)}
</>
@@ -136,7 +142,14 @@ export function DownloadGroup({
return download.status === "seeding" &&
download.downloader === Downloader.Torrent ? (
<>
<p>{t("seeding")}</p>
<p
data-open-article="seeding"
className="download-group__details-with-article"
>
{t("seeding")}
<QuestionIcon />
</p>
{uploadSpeed && <p>{uploadSpeed}/s</p>}
</>
) : (
@@ -174,7 +187,7 @@ export function DownloadGroup({
const deleting = isGameDeleting(game.id);
if (download?.progress === 1) {
if (game.download?.progress === 1) {
return [
{
label: t("install"),
@@ -189,8 +202,8 @@ export function DownloadGroup({
disabled: deleting,
icon: <UnlinkIcon />,
show:
download.status === "seeding" &&
download.downloader === Downloader.Torrent,
game.download?.status === "seeding" &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
pauseSeeding(game.shop, game.objectId);
},
@@ -200,8 +213,8 @@ export function DownloadGroup({
disabled: deleting,
icon: <LinkIcon />,
show:
download.status !== "seeding" &&
download.downloader === Downloader.Torrent,
game.download?.status !== "seeding" &&
game.download?.downloader === Downloader.Torrent,
onClick: () => {
resumeSeeding(game.shop, game.objectId);
},
@@ -217,7 +230,7 @@ export function DownloadGroup({
];
}
if (isGameDownloading || download?.status === "active") {
if (isGameDownloading) {
return [
{
label: t("pause"),

View File

@@ -8,7 +8,7 @@ import "./downloads.scss";
import { DeleteGameModal } from "./delete-game-modal";
import { DownloadGroup } from "./download-group";
import type { GameShop, LibraryGame, SeedingStatus } from "@types";
import { orderBy, sortBy } from "lodash-es";
import { orderBy } from "lodash-es";
import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() {
@@ -58,24 +58,24 @@ export default function Downloads() {
complete: [],
};
const result = sortBy(library, (game) => game.download?.timestamp).reduce(
(prev, next) => {
/* Game has been manually added to the library or has been canceled */
if (!next.download?.status || next.download?.status === "removed")
return prev;
const result = orderBy(
library,
(game) => game.download?.timestamp,
"desc"
).reduce((prev, next) => {
/* Game has been manually added to the library */
if (!next.download) return prev;
/* Is downloading */
if (lastPacket?.gameId === next.id)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is downloading */
if (lastPacket?.gameId === next.id)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
if (next.download.queued || next.download?.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
/* Is either queued or paused */
if (next.download.queued || next.download?.status === "paused")
return { ...prev, queued: [...prev.queued, next] };
return { ...prev, complete: [...prev.complete, next] };
},
initialValue
);
return { ...prev, complete: [...prev.complete, next] };
}, initialValue);
const queued = orderBy(result.queued, (game) => game.download?.timestamp, [
"desc",

View File

@@ -203,9 +203,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<div className="cloud-sync-modal__artifact-info">
<div className="cloud-sync-modal__artifact-header">
<h3>
{t("backup_from", {
date: format(artifact.createdAt, "dd/MM/yyyy"),
})}
{artifact.label ??
t("backup_from", {
date: format(artifact.createdAt, "dd/MM/yyyy"),
})}
</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div>

View File

@@ -16,13 +16,8 @@ import { useUserDetails } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
const HERO_HEIGHT = 300;
const HERO_ANIMATION_THRESHOLD = 25;
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const { t } = useTranslation("game_details");
@@ -61,7 +56,7 @@ export function GameDetailsContent() {
return t("no_shop_details");
}, [shopDetails, t]);
const [backdropOpactiy, setBackdropOpacity] = useState(1);
const [backdropOpacity, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
const output = await average(steamUrlBuilder.libraryHero(objectId!), {
@@ -80,26 +75,6 @@ export function GameDetailsContent() {
setBackdropOpacity(1);
}, [objectId]);
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? HERO_HEIGHT;
const scrollY = (event.target as HTMLDivElement).scrollTop;
const opacity = Math.max(
0,
1 - scrollY / (heroHeight - HERO_ANIMATION_THRESHOLD)
);
if (scrollY >= heroHeight && !isHeaderStuck) {
setIsHeaderStuck(true);
}
if (scrollY <= heroHeight && isHeaderStuck) {
setIsHeaderStuck(false);
}
setBackdropOpacity(opacity);
};
const handleCloudSaveButtonClick = () => {
if (!userDetails) {
window.electron.openAuthWindow(AuthPage.SignIn);
@@ -122,31 +97,25 @@ export function GameDetailsContent() {
<div
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
>
<img
src={steamUrlBuilder.libraryHero(objectId!)}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
/>
<section
ref={containerRef}
onScroll={onScroll}
className="game-details__container"
>
<section className="game-details__container">
<div ref={heroRef} className="game-details__hero">
<img
src={steamUrlBuilder.libraryHero(objectId!)}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div
className="game-details__hero-backdrop"
style={{
backgroundColor: gameColor,
flex: 1,
opacity: Math.min(1, 1 - backdropOpactiy),
}}
/>
<div
className="game-details__hero-logo-backdrop"
style={{ opacity: backdropOpactiy }}
style={{ opacity: backdropOpacity }}
>
<div className="game-details__hero-content">
<img
@@ -173,7 +142,7 @@ export function GameDetailsContent() {
</div>
</div>
<HeroPanel isHeaderStuck={isHeaderStuck} />
<HeroPanel />
<div className="game-details__description-container">
<div className="game-details__description-content">

View File

@@ -1,14 +1,17 @@
import {
DownloadIcon,
GearIcon,
HeartFillIcon,
HeartIcon,
PlayIcon,
PlusCircleIcon,
} from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel-actions.scss";
export function HeroPanelActions() {
@@ -37,6 +40,8 @@ export function HeroPanelActions() {
const { updateLibrary } = useLibrary();
const { showSuccessToast } = useToast();
const { t } = useTranslation("game_details");
const addGameToLibrary = async () => {
@@ -52,6 +57,31 @@ export function HeroPanelActions() {
}
};
const toggleGameFavorite = async () => {
setToggleLibraryGameDisabled(true);
try {
if (game?.favorite && objectId) {
await window.electron
.removeGameFromFavorites(shop, objectId)
.then(() => {
showSuccessToast(t("game_removed_from_favorites"));
});
} else {
if (!objectId) return;
await window.electron.addGameToFavorites(shop, objectId).then(() => {
showSuccessToast(t("game_added_to_favorites"));
});
}
updateLibrary();
updateGame();
} finally {
setToggleLibraryGameDisabled(false);
}
};
const openGame = async () => {
if (game) {
if (game.executablePath) {
@@ -159,6 +189,15 @@ export function HeroPanelActions() {
<div className="hero-panel-actions__container">
{gameActionButton()}
<div className="hero-panel-actions__separator" />
<Button
onClick={toggleGameFavorite}
theme="outline"
disabled={deleting}
className="hero-panel-actions__action"
>
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
</Button>
<Button
onClick={() => setShowGameOptionsModal(true)}
theme="outline"

View File

@@ -9,11 +9,7 @@ import { HeroPanelPlaytime } from "./hero-panel-playtime";
import { gameDetailsContext } from "@renderer/context";
import "./hero-panel.scss";
export interface HeroPanelProps {
isHeaderStuck: boolean;
}
export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
export function HeroPanel() {
const { t } = useTranslation("game_details");
const { formatDate } = useDate();
@@ -54,10 +50,7 @@ export function HeroPanel({ isHeaderStuck }: HeroPanelProps) {
game?.download?.status === "paused";
return (
<div
style={{ backgroundColor: gameColor }}
className={`hero-panel ${isHeaderStuck ? "hero-panel--stuck" : ""}`}
>
<div style={{ backgroundColor: gameColor }} className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />

View File

@@ -44,10 +44,9 @@ export function DownloadSettingsModal({
(state) => state.userPreferences.value
);
const getDiskFreeSpace = (path: string) => {
window.electron.getDiskFreeSpace(path).then((result) => {
setDiskFreeSpace(result.free);
});
const getDiskFreeSpace = async (path: string) => {
const result = await window.electron.getDiskFreeSpace(path);
setDiskFreeSpace(result.free);
};
const checkFolderWritePermission = useCallback(
@@ -100,6 +99,7 @@ export function DownloadSettingsModal({
userPreferences?.downloadsPath,
downloaders,
userPreferences?.realDebridApiToken,
userPreferences?.torBoxApiToken,
]);
const handleChooseDownloadsPath = async () => {
@@ -155,25 +155,30 @@ export function DownloadSettingsModal({
<span>{t("downloader")}</span>
<div className="download-settings-modal__downloaders">
{downloaders.map((downloader) => (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
disabled={
downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
))}
{downloaders.map((downloader) => {
const shouldDisableButton =
(downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken) ||
(downloader === Downloader.TorBox &&
!userPreferences?.torBoxApiToken);
return (
<Button
key={downloader}
className="download-settings-modal__downloader-option"
theme={
selectedDownloader === downloader ? "primary" : "outline"
}
disabled={shouldDisableButton}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className="download-settings-modal__downloader-icon" />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
);
})}
</div>
</div>

View File

@@ -23,6 +23,20 @@
}
}
&__cloud-sync-label {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
&__cloud-sync-hydra-cloud {
background: linear-gradient(270deg, #16b195 50%, #3e62c0 100%);
color: #fff;
padding: 0 globals.$spacing-unit;
border-radius: 4px;
font-size: globals.$small-font-size;
}
&__row {
display: flex;
gap: globals.$spacing-unit;

View File

@@ -1,6 +1,6 @@
import { useContext, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { Button, CheckboxField, Modal, TextField } from "@renderer/components";
import type { LibraryGame } from "@types";
import { gameDetailsContext } from "@renderer/context";
import { DeleteGameModal } from "@renderer/pages/downloads/delete-game-modal";
@@ -34,12 +34,17 @@ export function GameOptionsModal({
achievements,
} = useContext(gameDetailsContext);
const { hasActiveSubscription } = useUserDetails();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showRemoveGameModal, setShowRemoveGameModal] = useState(false);
const [launchOptions, setLaunchOptions] = useState(game.launchOptions ?? "");
const [showResetAchievementsModal, setShowResetAchievementsModal] =
useState(false);
const [isDeletingAchievements, setIsDeletingAchievements] = useState(false);
const [automaticCloudSync, setAutomaticCloudSync] = useState(
game.automaticCloudSync ?? false
);
const {
removeGameInstaller,
@@ -183,6 +188,20 @@ export function GameOptionsModal({
}
};
const handleToggleAutomaticCloudSync = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
setAutomaticCloudSync(event.target.checked);
await window.electron.toggleAutomaticCloudSync(
game.shop,
game.objectId,
event.target.checked
);
updateGame();
};
return (
<>
<DeleteGameModal
@@ -266,6 +285,20 @@ export function GameOptionsModal({
</div>
</div>
<CheckboxField
label={
<div className="game-options-modal__cloud-sync-label">
{t("enable_automatic_cloud_sync")}
<span className="game-options-modal__cloud-sync-hydra-cloud">
Hydra Cloud
</span>
</div>
}
checked={automaticCloudSync}
disabled={!hasActiveSubscription || !game.executablePath}
onChange={handleToggleAutomaticCloudSync}
/>
{shouldShowWinePrefixConfiguration && (
<div className="game-options-modal__wine-prefix">
<div className="game-options-modal__header">

View File

@@ -27,6 +27,11 @@
}
}
&__badges {
display: flex;
gap: calc(globals.$spacing-unit / 2);
}
&__user-information {
display: flex;
padding: calc(globals.$spacing-unit * 7) calc(globals.$spacing-unit * 3);
@@ -65,6 +70,12 @@
overflow: hidden;
}
&__display-name-container {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
}
&__display-name {
font-weight: bold;
overflow: hidden;

View File

@@ -24,6 +24,7 @@ import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import { Tooltip } from "react-tooltip";
import "./profile-hero.scss";
type FriendAction =
@@ -34,8 +35,14 @@ export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext);
const {
isMe,
badges,
getUserProfile,
userProfile,
heroBackground,
backgroundImage,
} = useContext(userProfileContext);
const {
signOut,
updateFriendRequestState,
@@ -260,14 +267,6 @@ export function ProfileHero() {
return (
<>
{/* <ConfirmationModal
visible
title={t("sign_out_modal_title")}
descriptionText={t("sign_out_modal_text")}
confirmButtonLabel={t("sign_out")}
cancelButtonLabel={t("cancel")}
/> */}
<EditProfileModal
visible={showEditProfileModal}
onClose={() => setShowEditProfileModal(false)}
@@ -307,9 +306,34 @@ export function ProfileHero() {
<div className="profile-hero__information">
{userProfile ? (
<h2 className="profile-hero__display-name">
{userProfile?.displayName}
</h2>
<div className="profile-hero__display-name-container">
<h2 className="profile-hero__display-name">
{userProfile?.displayName}
</h2>
<div className="profile-hero__badges">
{userProfile.badges.map((badgeName) => {
const badge = badges.find((b) => b.name === badgeName);
if (!badge) return null;
return (
<img
key={badge.name}
src={badge.badge.url}
alt={badge.name}
width={24}
height={24}
data-tooltip-place="top"
data-tooltip-content={badge.description}
data-tooltip-id="badge-name"
/>
);
})}
<Tooltip id="badge-name" />
</div>
</div>
) : (
<Skeleton width={150} height={28} />
)}

View File

@@ -74,7 +74,10 @@ export function ReportProfile() {
title={t("report_profile")}
clickOutsideToClose={false}
>
<form className="report-profile__form">
<form
onSubmit={handleSubmit(onSubmit)}
className="report-profile__form"
>
<Controller
control={control}
name="reason"
@@ -101,12 +104,7 @@ export function ReportProfile() {
error={errors.description?.message}
/>
<Button
className="report-profile__submit"
onClick={handleSubmit(onSubmit)}
>
{t("report")}
</Button>
<Button className="report-profile__submit">{t("report")}</Button>
</form>
</Modal>

View File

@@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
visible,
onClose,
onAddDownloadSource,
}: AddDownloadSourceModalProps) {
}: Readonly<AddDownloadSourceModalProps>) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);

View File

@@ -0,0 +1,25 @@
@use "../../../../scss/globals.scss";
.settings-appearance {
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
&-left {
display: flex;
gap: 8px;
}
&-right {
display: flex;
gap: 8px;
}
}
&__button {
display: flex;
align-items: center;
gap: 8px;
}
}

View File

@@ -0,0 +1,75 @@
import { GlobeIcon, TrashIcon, PlusIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components/button/button";
import { useTranslation } from "react-i18next";
import { AddThemeModal, DeleteAllThemesModal } from "../index";
import "./theme-actions.scss";
import { useState } from "react";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
interface ThemeActionsProps {
onListUpdated: () => void;
themesCount: number;
}
export const ThemeActions = ({
onListUpdated,
themesCount,
}: ThemeActionsProps) => {
const { t } = useTranslation("settings");
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
const [deleteAllThemesModalVisible, setDeleteAllThemesModalVisible] =
useState(false);
return (
<>
<AddThemeModal
visible={addThemeModalVisible}
onClose={() => setAddThemeModalVisible(false)}
onThemeAdded={onListUpdated}
/>
<DeleteAllThemesModal
visible={deleteAllThemesModalVisible}
onClose={() => setDeleteAllThemesModalVisible(false)}
onThemesDeleted={onListUpdated}
/>
<div className="settings-appearance__actions">
<div className="settings-appearance__actions-left">
<Button
theme="primary"
className="settings-appearance__button"
onClick={() => {
window.open(THEME_WEB_STORE_URL, "_blank");
}}
>
<GlobeIcon />
{t("web_store")}
</Button>
<Button
theme="danger"
className="settings-appearance__button"
onClick={() => setDeleteAllThemesModalVisible(true)}
disabled={themesCount < 1}
>
<TrashIcon />
{t("clear_themes")}
</Button>
</div>
<div className="settings-appearance__actions-right">
<Button
theme="outline"
className="settings-appearance__button"
onClick={() => setAddThemeModalVisible(true)}
>
<PlusIcon />
{t("create_theme")}
</Button>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,97 @@
@use "../../../../scss/globals.scss";
.theme-card {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
background-color: rgba(globals.$border-color, 0.01);
border: 1px solid globals.$border-color;
border-radius: 12px;
gap: 4px;
transition: background-color 0.2s ease;
padding: 16px;
position: relative;
&--active {
background-color: rgba(globals.$border-color, 0.04);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
gap: 16px;
&__title {
font-size: 18px;
font-weight: 600;
color: globals.$muted-color;
text-transform: capitalize;
}
&__colors {
display: flex;
flex-direction: row;
gap: 8px;
&__color {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid globals.$border-color;
}
}
}
&__author {
font-size: 12px;
color: globals.$body-color;
font-weight: 400;
&__name {
font-weight: 600;
color: rgba(globals.$muted-color, 0.8);
margin-left: 4px;
&:hover {
color: globals.$muted-color;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
&__actions {
display: flex;
flex-direction: row;
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
gap: 8px;
justify-content: space-between;
&__left {
display: flex;
flex-direction: row;
gap: 8px;
}
&__right {
display: flex;
flex-direction: row;
gap: 8px;
&--external {
display: none;
}
Button {
padding: 8px 11px;
}
}
}
}

View File

@@ -0,0 +1,130 @@
import { PencilIcon, TrashIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components/button/button";
import type { Theme } from "@types";
import { useNavigate } from "react-router-dom";
import "./theme-card.scss";
import { useState } from "react";
import { DeleteThemeModal } from "../modals/delete-theme-modal";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
interface ThemeCardProps {
theme: Theme;
onListUpdated: () => void;
}
export const ThemeCard = ({ theme, onListUpdated }: ThemeCardProps) => {
const { t } = useTranslation("settings");
const navigate = useNavigate();
const [deleteThemeModalVisible, setDeleteThemeModalVisible] = useState(false);
const handleSetTheme = async () => {
try {
const currentTheme = await window.electron.getCustomThemeById(theme.id);
if (!currentTheme) return;
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
await window.electron.toggleCustomTheme(activeTheme.id, false);
}
if (currentTheme.code) {
injectCustomCss(currentTheme.code);
}
await window.electron.toggleCustomTheme(currentTheme.id, true);
onListUpdated();
} catch (error) {
console.error(error);
}
};
const handleUnsetTheme = async () => {
try {
removeCustomCss();
await window.electron.toggleCustomTheme(theme.id, false);
onListUpdated();
} catch (error) {
console.error(error);
}
};
return (
<>
<DeleteThemeModal
visible={deleteThemeModalVisible}
onClose={() => setDeleteThemeModalVisible(false)}
onThemeDeleted={onListUpdated}
themeId={theme.id}
themeName={theme.name}
isActive={theme.isActive}
/>
<div
className={`theme-card ${theme.isActive ? "theme-card--active" : ""}`}
key={theme.name}
>
<div className="theme-card__header">
<div className="theme-card__header__title">{theme.name}</div>
</div>
{theme.authorName && (
<p className="theme-card__author">
{t("by")}
<button
className="theme-card__author__name"
onClick={() => navigate(`/profile/${theme.author}`)}
>
{theme.authorName}
</button>
</p>
)}
<div className="theme-card__actions">
<div className="theme-card__actions__left">
{theme.isActive ? (
<Button onClick={handleUnsetTheme} theme="dark">
{t("unset_theme")}
</Button>
) : (
<Button onClick={handleSetTheme} theme="outline">
{t("set_theme")}
</Button>
)}
</div>
<div className="theme-card__actions__right">
<Button
className={
theme.code.startsWith(THEME_WEB_STORE_URL)
? "theme-card__actions__right--external"
: ""
}
onClick={() => window.electron.openEditorWindow(theme.id)}
title={t("edit_theme")}
theme="outline"
>
<PencilIcon />
</Button>
<Button
onClick={() => setDeleteThemeModalVisible(true)}
title={t("delete_theme")}
theme="outline"
>
<TrashIcon />
</Button>
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,39 @@
@use "../../../../scss/globals.scss";
.theme-placeholder {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 24px;
background-color: rgba(globals.$border-color, 0.01);
cursor: pointer;
border: 1px dashed globals.$border-color;
border-radius: 12px;
gap: 12px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(globals.$border-color, 0.03);
}
&__icon {
svg {
width: 32px;
height: 32px;
color: globals.$body-color;
opacity: 0.7;
}
}
&__text {
text-align: center;
max-width: 400px;
font-size: 14.5px;
line-height: 1.6;
font-weight: 400;
color: rgba(globals.$body-color, 0.85);
}
}

View File

@@ -0,0 +1,36 @@
import { AlertIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./theme-placeholder.scss";
import { AddThemeModal } from "../modals/add-theme-modal";
import { useState } from "react";
interface ThemePlaceholderProps {
onListUpdated: () => void;
}
export const ThemePlaceholder = ({ onListUpdated }: ThemePlaceholderProps) => {
const { t } = useTranslation("settings");
const [addThemeModalVisible, setAddThemeModalVisible] = useState(false);
return (
<>
<AddThemeModal
visible={addThemeModalVisible}
onClose={() => setAddThemeModalVisible(false)}
onThemeAdded={onListUpdated}
/>
<button
className="theme-placeholder"
onClick={() => setAddThemeModalVisible(true)}
>
<div className="theme-placeholder__icon">
<AlertIcon />
</div>
<p className="theme-placeholder__text">{t("no_themes")}</p>
</button>
</>
);
};

View File

@@ -0,0 +1,7 @@
export { SettingsAppearance } from "./settings-appearance";
export { AddThemeModal } from "./modals/add-theme-modal";
export { DeleteAllThemesModal } from "./modals/delete-all-themes-modal";
export { DeleteThemeModal } from "./modals/delete-theme-modal";
export { ThemeCard } from "./components/theme-card";
export { ThemePlaceholder } from "./components/theme-placeholder";
export { ThemeActions } from "./components/theme-actions";

View File

@@ -0,0 +1,127 @@
import { Modal } from "@renderer/components/modal/modal";
import { TextField } from "@renderer/components/text-field/text-field";
import { Button } from "@renderer/components/button/button";
import { useTranslation } from "react-i18next";
import { useUserDetails } from "@renderer/hooks";
import { Theme } from "@types";
import { useForm } from "react-hook-form";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { useCallback } from "react";
import "./modals.scss";
interface AddThemeModalProps {
visible: boolean;
onClose: () => void;
onThemeAdded: () => void;
}
interface FormValues {
name: string;
}
const DEFAULT_THEME_CODE = `
/*
Here you can edit CSS for your theme and apply it on Hydra.
There are a few classes already in place, you can use them to style the launcher.
If you want to learn more about how to run Hydra in dev mode (which will allow you to inspect the DOM and view the classes)
or how to publish your theme in the theme store, you can check the docs:
https://docs.hydralauncher.gg/
Happy hacking!
*/
/* Header */
.header {}
/* Sidebar */
.sidebar {}
/* Main content */
.container__content {}
/* Bottom panel */
.bottom-panel {}
/* Toast */
.toast {}
/* Button */
.button {}
`;
export function AddThemeModal({
visible,
onClose,
onThemeAdded,
}: Readonly<AddThemeModalProps>) {
const { t } = useTranslation("settings");
const { userDetails } = useUserDetails();
const schema = yup.object({
name: yup
.string()
.required(t("required_field"))
.min(3, t("name_min_length")),
});
const {
register,
handleSubmit,
reset,
formState: { isSubmitting, errors },
} = useForm<FormValues>({
resolver: yupResolver(schema),
});
const onSubmit = useCallback(
async (values: FormValues) => {
const theme: Theme = {
id: crypto.randomUUID(),
name: values.name,
isActive: false,
author: userDetails?.id,
authorName: userDetails?.username,
code: DEFAULT_THEME_CODE,
createdAt: new Date(),
updatedAt: new Date(),
};
await window.electron.addCustomTheme(theme);
onThemeAdded();
onClose();
reset();
},
[onClose, onThemeAdded, userDetails?.id, userDetails?.username, reset]
);
return (
<Modal
visible={visible}
title={t("create_theme_modal_title")}
description={t("create_theme_modal_description")}
onClose={onClose}
>
<form
onSubmit={handleSubmit(onSubmit)}
className="add-theme-modal__container"
>
<TextField
{...register("name")}
label={t("theme_name")}
placeholder={t("insert_theme_name")}
hint={errors.name?.message}
error={errors.name?.message}
/>
<Button type="submit" theme="primary" disabled={isSubmitting}>
{t("create_theme")}
</Button>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,51 @@
import { Button } from "@renderer/components/button/button";
import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { removeCustomCss } from "@renderer/helpers";
interface DeleteAllThemesModalProps {
visible: boolean;
onClose: () => void;
onThemesDeleted: () => void;
}
export const DeleteAllThemesModal = ({
visible,
onClose,
onThemesDeleted,
}: DeleteAllThemesModalProps) => {
const { t } = useTranslation("settings");
const handleDeleteAllThemes = async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
}
await window.electron.deleteAllCustomThemes();
await window.electron.closeEditorWindow();
onClose();
onThemesDeleted();
};
return (
<Modal
visible={visible}
title={t("delete_all_themes")}
description={t("delete_all_themes_description")}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">
<Button theme="outline" onClick={handleDeleteAllThemes}>
{t("delete_all_themes")}
</Button>
<Button theme="primary" onClick={onClose}>
{t("cancel")}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,54 @@
import { Button } from "@renderer/components/button/button";
import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { removeCustomCss } from "@renderer/helpers";
interface DeleteThemeModalProps {
visible: boolean;
onClose: () => void;
themeId: string;
isActive: boolean;
onThemeDeleted: () => void;
themeName: string;
}
export const DeleteThemeModal = ({
visible,
onClose,
themeId,
isActive,
onThemeDeleted,
themeName,
}: DeleteThemeModalProps) => {
const { t } = useTranslation("settings");
const handleDeleteTheme = async () => {
if (isActive) {
removeCustomCss();
}
await window.electron.deleteCustomTheme(themeId);
await window.electron.closeEditorWindow(themeId);
onThemeDeleted();
};
return (
<Modal
visible={visible}
title={t("delete_theme")}
description={t("delete_theme_description", { theme: themeName })}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">
<Button theme="outline" onClick={handleDeleteTheme}>
{t("delete_theme")}
</Button>
<Button theme="primary" onClick={onClose}>
{t("cancel")}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,90 @@
import { Button } from "@renderer/components/button/button";
import { Modal } from "@renderer/components/modal/modal";
import { useTranslation } from "react-i18next";
import "./modals.scss";
import { Theme } from "@types";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import { useToast } from "@renderer/hooks";
import { THEME_WEB_STORE_URL } from "@renderer/constants";
import { logger } from "@renderer/logger";
interface ImportThemeModalProps {
visible: boolean;
onClose: () => void;
onThemeImported: () => void;
themeName: string;
authorId: string;
authorName: string;
}
export const ImportThemeModal = ({
visible,
onClose,
onThemeImported,
themeName,
authorId,
authorName,
}: ImportThemeModalProps) => {
const { t } = useTranslation("settings");
const { showSuccessToast, showErrorToast } = useToast();
const handleImportTheme = async () => {
const theme: Theme = {
id: crypto.randomUUID(),
name: themeName,
isActive: false,
author: authorId,
authorName: authorName,
code: `${THEME_WEB_STORE_URL}/themes/${themeName.toLowerCase()}/theme.css`,
createdAt: new Date(),
updatedAt: new Date(),
};
try {
await window.electron.addCustomTheme(theme);
const currentTheme = await window.electron.getCustomThemeById(theme.id);
if (!currentTheme) return;
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {
removeCustomCss();
await window.electron.toggleCustomTheme(activeTheme.id, false);
}
if (currentTheme.code) {
injectCustomCss(currentTheme.code);
}
await window.electron.toggleCustomTheme(currentTheme.id, true);
onThemeImported();
showSuccessToast(t("theme_imported"));
onClose();
} catch (error) {
logger.error(error);
showErrorToast(t("error_importing_theme"));
onClose();
}
};
return (
<Modal
visible={visible}
title={t("import_theme")}
description={t("import_theme_description", { theme: themeName })}
onClose={onClose}
>
<div className="delete-all-themes-modal__container">
<Button theme="outline" onClick={handleImportTheme}>
{t("import_theme")}
</Button>
<Button theme="primary" onClick={onClose}>
{t("cancel")}
</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,15 @@
.add-theme-modal {
&__container {
display: flex;
flex-direction: column;
gap: 16px;
}
}
.delete-all-themes-modal__container {
display: flex;
flex-direction: row;
gap: 8px;
width: 100%;
justify-content: flex-end;
}

View File

@@ -0,0 +1,154 @@
@use "../../../scss/globals.scss";
.settings-appearance {
display: flex;
flex-direction: column;
gap: 16px;
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
&-left {
display: flex;
gap: 8px;
}
}
&__themes {
display: flex;
flex-direction: column;
gap: 16px;
&__theme {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
background-color: rgba(globals.$border-color, 0.01);
border: 1px solid globals.$border-color;
border-radius: 12px;
gap: 4px;
transition: background-color 0.2s ease;
padding: 16px;
position: relative;
&--active {
background-color: rgba(globals.$border-color, 0.04);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
gap: 16px;
&__title {
font-size: 18px;
font-weight: 600;
color: globals.$muted-color;
text-transform: capitalize;
}
&__colors {
display: flex;
flex-direction: row;
gap: 8px;
&__color {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid globals.$border-color;
}
}
}
&__author {
font-size: 12px;
color: globals.$body-color;
font-weight: 400;
&__name {
font-weight: 600;
color: rgba(globals.$muted-color, 0.8);
margin-left: 4px;
&:hover {
color: globals.$muted-color;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
&__actions {
display: flex;
flex-direction: row;
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
gap: 8px;
justify-content: space-between;
&__left {
display: flex;
flex-direction: row;
gap: 8px;
}
&__right {
display: flex;
flex-direction: row;
gap: 8px;
Button {
padding: 8px 11px;
}
}
}
}
}
&__no-themes {
width: 100%;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 24px;
background-color: rgba(globals.$border-color, 0.01);
cursor: pointer;
border: 1px dashed globals.$border-color;
border-radius: 12px;
gap: 12px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(globals.$border-color, 0.03);
}
&__icon {
svg {
width: 32px;
height: 32px;
color: globals.$body-color;
opacity: 0.7;
}
}
&__text {
text-align: center;
max-width: 400px;
font-size: 14.5px;
line-height: 1.6;
font-weight: 400;
color: rgba(globals.$body-color, 0.85);
}
}
}

View File

@@ -0,0 +1,123 @@
import { useCallback, useContext, useEffect, useState } from "react";
import "./settings-appearance.scss";
import { ThemeActions, ThemeCard, ThemePlaceholder } from "./index";
import type { Theme } from "@types";
import { ImportThemeModal } from "./modals/import-theme-modal";
import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom";
interface SettingsAppearanceProps {
appearance: {
theme: string | null;
authorId: string | null;
authorName: string | null;
};
}
export function SettingsAppearance({
appearance,
}: Readonly<SettingsAppearanceProps>) {
const [themes, setThemes] = useState<Theme[]>([]);
const [isImportThemeModalVisible, setIsImportThemeModalVisible] =
useState(false);
const [importTheme, setImportTheme] = useState<{
theme: string;
authorId: string;
authorName: string;
} | null>(null);
const [hasShownModal, setHasShownModal] = useState(false);
const { clearTheme } = useContext(settingsContext);
const navigate = useNavigate();
const loadThemes = useCallback(async () => {
const themesList = await window.electron.getAllCustomThemes();
setThemes(themesList);
}, []);
useEffect(() => {
loadThemes();
}, [loadThemes]);
useEffect(() => {
const unsubscribe = window.electron.onCssInjected(() => {
loadThemes();
});
return () => unsubscribe();
}, [loadThemes]);
useEffect(() => {
if (
appearance.theme &&
appearance.authorId &&
appearance.authorName &&
!hasShownModal
) {
setIsImportThemeModalVisible(true);
setImportTheme({
theme: appearance.theme,
authorId: appearance.authorId,
authorName: appearance.authorName,
});
setHasShownModal(true);
navigate("/settings", { replace: true });
clearTheme();
}
}, [
appearance.theme,
appearance.authorId,
appearance.authorName,
navigate,
hasShownModal,
clearTheme,
]);
const onThemeImported = useCallback(() => {
setIsImportThemeModalVisible(false);
setImportTheme(null);
loadThemes();
}, [loadThemes]);
return (
<div className="settings-appearance">
<ThemeActions onListUpdated={loadThemes} themesCount={themes.length} />
<div className="settings-appearance__themes">
{!themes.length ? (
<ThemePlaceholder onListUpdated={loadThemes} />
) : (
[...themes]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime()
)
.map((theme) => (
<ThemeCard
key={theme.id}
theme={theme}
onListUpdated={loadThemes}
/>
))
)}
</div>
{importTheme && (
<ImportThemeModal
visible={isImportThemeModalVisible}
onClose={() => {
setIsImportThemeModalVisible(false);
clearTheme();
setHasShownModal(false);
}}
onThemeImported={onThemeImported}
themeName={importTheme.theme}
authorId={importTheme.authorId}
authorName={importTheme.authorName}
/>
)}
</div>
);
}

View File

@@ -63,7 +63,7 @@ export function SettingsAccount() {
return () => {
unsubscribe();
};
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
}, [fetchUserDetails, updateUserDetails, t, showSuccessToast]);
const visibilityOptions = [
{ value: "PUBLIC", label: t("public") },

View File

@@ -86,12 +86,12 @@ export function SettingsRealDebrid() {
<CheckboxField
label={t("enable_real_debrid")}
checked={form.useRealDebrid}
onChange={() =>
onChange={() => {
setForm((prev) => ({
...prev,
useRealDebrid: !form.useRealDebrid,
}))
}
}));
}}
/>
{form.useRealDebrid && (

View File

@@ -10,9 +10,10 @@ import {
SettingsContextProvider,
} from "@renderer/context";
import { SettingsAccount } from "./settings-account";
import { useUserDetails } from "@renderer/hooks";
import { useFeature, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react";
import "./settings.scss";
import { SettingsAppearance } from "./aparence/settings-appearance";
import { SettingsTorbox } from "./settings-torbox";
export default function Settings() {
@@ -20,20 +21,36 @@ export default function Settings() {
const { userDetails } = useUserDetails();
const { isFeatureEnabled, Feature } = useFeature();
const isTorboxEnabled = isFeatureEnabled(Feature.Torbox);
const categories = useMemo(() => {
const categories = [
{ tabLabel: t("general"), contentTitle: t("general") },
{ tabLabel: t("behavior"), contentTitle: t("behavior") },
{ tabLabel: t("download_sources"), contentTitle: t("download_sources") },
{
tabLabel: (
<>
<img src={torBoxLogo} alt="TorBox" style={{ width: 13 }} />
Torbox
</>
),
contentTitle: "TorBox",
tabLabel: t("appearance"),
contentTitle: t("appearance"),
},
...(isTorboxEnabled
? [
{
tabLabel: (
<>
<img
src={torBoxLogo}
alt="TorBox"
style={{ width: 13, height: 13 }}
/>{" "}
Torbox
</>
),
contentTitle: "TorBox",
},
]
: []),
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
];
@@ -43,12 +60,12 @@ export default function Settings() {
{ tabLabel: t("account"), contentTitle: t("account") },
];
return categories;
}, [userDetails, t]);
}, [userDetails, t, isTorboxEnabled]);
return (
<SettingsContextProvider>
<SettingsContextConsumer>
{({ currentCategoryIndex, setCurrentCategoryIndex }) => {
{({ currentCategoryIndex, setCurrentCategoryIndex, appearance }) => {
const renderCategory = () => {
if (currentCategoryIndex === 0) {
return <SettingsGeneral />;
@@ -63,10 +80,14 @@ export default function Settings() {
}
if (currentCategoryIndex === 3) {
return <SettingsTorbox />;
return <SettingsAppearance appearance={appearance} />;
}
if (currentCategoryIndex === 4) {
return <SettingsTorbox />;
}
if (currentCategoryIndex === 5) {
return <SettingsRealDebrid />;
}
@@ -79,7 +100,7 @@ export default function Settings() {
<section className="settings__categories">
{categories.map((category, index) => (
<Button
key={index}
key={category.contentTitle}
theme={
currentCategoryIndex === index ? "primary" : "outline"
}

View File

@@ -106,12 +106,10 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
<div className="user-friend-item__container">
<div className="user-friend-item__button">
<Avatar size={35} src={profileImageUrl} alt={displayName} />
<div className="user-friend-item__button__content">
<p className="user-friend-item__display-name">{displayName}</p>
</div>
</div>
<div className="user-friend-item__button__actions">
{getRequestActions()}
</div>
@@ -133,7 +131,6 @@ export const UserFriendItem = (props: UserFriendItemProps) => {
{getRequestDescription()}
</div>
</button>
<div className="user-friend-item__button__actions">
{getRequestActions()}
</div>

View File

@@ -0,0 +1,77 @@
@use "../../scss/globals.scss";
.theme-editor {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&__header {
display: flex;
align-items: center;
padding: calc(globals.$spacing-unit + 1px);
background-color: globals.$dark-background-color;
font-size: 8px;
z-index: 50;
-webkit-app-region: drag;
gap: 8px;
&--darwin {
padding-top: calc(globals.$spacing-unit * 6);
}
h1 {
margin: 0;
line-height: 1;
}
&__status {
display: flex;
width: 9px;
height: 9px;
background-color: globals.$muted-color;
border-radius: 50%;
margin-top: 3px;
}
}
&__footer {
background-color: globals.$dark-background-color;
padding: globals.$spacing-unit globals.$spacing-unit * 2;
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
&-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
&__tabs {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 8px;
.active {
background-color: darken(globals.$dark-background-color, 2%);
}
}
}
}
&__info {
padding: 16px;
p {
font-size: 16px;
font-weight: 600;
color: globals.$muted-color;
margin-bottom: 8px;
}
}
}

View File

@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import "./theme-editor.scss";
import Editor from "@monaco-editor/react";
import { Theme } from "@types";
import { useSearchParams } from "react-router-dom";
import { Button } from "@renderer/components";
import { CheckIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
export default function ThemeEditor() {
const [searchParams] = useSearchParams();
const [theme, setTheme] = useState<Theme | null>(null);
const [code, setCode] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const themeId = searchParams.get("themeId");
const { t } = useTranslation("settings");
useEffect(() => {
if (themeId) {
window.electron.getCustomThemeById(themeId).then((loadedTheme) => {
if (loadedTheme) {
setTheme(loadedTheme);
setCode(loadedTheme.code);
}
});
}
}, [themeId]);
const handleSave = useCallback(async () => {
if (theme) {
await window.electron.updateCustomTheme(theme.id, code);
setHasUnsavedChanges(false);
}
}, [code, theme]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
event.preventDefault();
handleSave();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [code, handleSave, theme]);
const handleEditorChange = (value: string | undefined) => {
if (value !== undefined) {
setCode(value);
setHasUnsavedChanges(true);
}
};
return (
<div className="theme-editor">
<div
className={cn("theme-editor__header", {
"theme-editor__header--darwin": window.electron.platform === "darwin",
})}
>
<h1>{theme?.name}</h1>
{hasUnsavedChanges && (
<div className="theme-editor__header__status"></div>
)}
</div>
<Editor
theme="vs-dark"
defaultLanguage="css"
value={code}
onChange={handleEditorChange}
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: "on",
wordWrap: "on",
automaticLayout: true,
}}
/>
<div className="theme-editor__footer">
<div className="theme-editor__footer-actions">
<Button onClick={handleSave}>
<CheckIcon />
{t("editor_tab_save")}
</Button>
</div>
</div>
</div>
);
}