mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 16:53:57 +00:00
merge branch 'main' of https://github.com/KelvinDiasMoreira/hydra into feature/delete-all-dowload-sources
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
cursor: pointer;
|
||||
color: globals.$muted-color;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&__image {
|
||||
height: 100%;
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -15,7 +15,7 @@ export function Button({
|
||||
theme = "primary",
|
||||
className,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
}: Readonly<ButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface CheckboxFieldProps
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
HTMLInputElement
|
||||
> {
|
||||
label: string;
|
||||
label: string | React.ReactNode;
|
||||
}
|
||||
|
||||
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
|
||||
|
||||
50
src/renderer/src/components/sidebar/sidebar-game-item.tsx
Normal file
50
src/renderer/src/components/sidebar/sidebar-game-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
32
src/renderer/src/declaration.d.ts
vendored
32
src/renderer/src/declaration.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export function DeleteGameModal({
|
||||
onClose,
|
||||
visible,
|
||||
deleteGame,
|
||||
}: DeleteGameModalProps) {
|
||||
}: Readonly<DeleteGameModalProps>) {
|
||||
const { t } = useTranslation("downloads");
|
||||
|
||||
const handleDeleteGame = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export function AddDownloadSourceModal({
|
||||
visible,
|
||||
onClose,
|
||||
onAddDownloadSource,
|
||||
}: AddDownloadSourceModalProps) {
|
||||
}: Readonly<AddDownloadSourceModalProps>) {
|
||||
const [url, setUrl] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
7
src/renderer/src/pages/settings/aparence/index.ts
Normal file
7
src/renderer/src/pages/settings/aparence/index.ts
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
15
src/renderer/src/pages/settings/aparence/modals/modals.scss
Normal file
15
src/renderer/src/pages/settings/aparence/modals/modals.scss
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
123
src/renderer/src/pages/settings/aparence/settings-appearance.tsx
Normal file
123
src/renderer/src/pages/settings/aparence/settings-appearance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function SettingsAccount() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [fetchUserDetails, updateUserDetails, showSuccessToast]);
|
||||
}, [fetchUserDetails, updateUserDetails, t, showSuccessToast]);
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "PUBLIC", label: t("public") },
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
77
src/renderer/src/pages/theme-editor/theme-editor.scss
Normal file
77
src/renderer/src/pages/theme-editor/theme-editor.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/renderer/src/pages/theme-editor/theme-editor.tsx
Normal file
98
src/renderer/src/pages/theme-editor/theme-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user