feat: adding import download source

This commit is contained in:
Chubby Granny Chaser 2024-06-03 02:12:05 +01:00
parent ddd9ea69df
commit 48e07370e4
No known key found for this signature in database
70 changed files with 925 additions and 1261 deletions

View file

@ -23,6 +23,7 @@ export const heroMedia = style({
width: "100%",
height: "100%",
transition: "all ease 0.2s",
imageRendering: "revert",
selectors: {
[`${hero}:hover &`]: {
transform: "scale(1.02)",

View file

@ -21,7 +21,7 @@ export const modal = recipe({
animationName: fadeIn,
animationDuration: "0.3s",
backgroundColor: vars.color.background,
borderRadius: "5px",
borderRadius: "4px",
maxWidth: "600px",
color: vars.color.body,
maxHeight: "100%",

View file

@ -42,18 +42,33 @@ export const textField = recipe({
},
});
export const textFieldInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
export const textFieldInput = recipe({
base: {
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
},
variants: {
readOnly: {
true: {
textOverflow: "inherit",
},
},
},
});
export const togglePasswordButton = style({
cursor: "pointer",
color: vars.color.muted,
padding: `${SPACING_UNIT}px`,
});

View file

@ -1,6 +1,8 @@
import { useId, useState } from "react";
import { useId, useMemo, useState } from "react";
import type { RecipeVariants } from "@vanilla-extract/recipes";
import * as styles from "./text-field.css";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
export interface TextFieldProps
extends React.DetailedHTMLProps<
@ -28,9 +30,20 @@ export function TextField({
containerProps,
...props
}: TextFieldProps) {
const [isFocused, setIsFocused] = useState(false);
const id = useId();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
return (
<div className={styles.textFieldContainer} {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
@ -41,12 +54,27 @@ export function TextField({
>
<input
id={id}
type="text"
className={styles.textFieldInput}
className={styles.textFieldInput({ readOnly: props.readOnly })}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
type={inputType}
/>
{showPasswordToggleButton && (
<button
type="button"
className={styles.togglePasswordButton}
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
aria-label={t("toggle_password_visibility")}
>
{isPasswordVisible ? (
<EyeClosedIcon size={16} />
) : (
<EyeIcon size={16} />
)}
</button>
)}
</div>
{hint && <small>{hint}</small>}

View file

@ -12,6 +12,7 @@ import type {
UserPreferences,
StartGameDownloadPayload,
RealDebridUser,
DownloadSource,
} from "@types";
import type { DiskSpace } from "check-disk-space";
@ -33,7 +34,7 @@ declare global {
/* Catalogue */
searchGames: (query: string) => Promise<CatalogueEntry[]>;
getCatalogue: (category: CatalogueCategory) => Promise<CatalogueEntry[]>;
getCatalogue: () => Promise<CatalogueEntry[]>;
getGameShopDetails: (
objectID: string,
shop: GameShop,
@ -58,7 +59,7 @@ declare global {
shop: GameShop,
executablePath: string | null
) => Promise<void>;
getLibrary: () => Promise<Game[]>;
getLibrary: () => Promise<Omit<Game, "repacks">[]>;
openGameInstaller: (gameId: number) => Promise<boolean>;
openGame: (gameId: number, executablePath: string) => Promise<void>;
closeGame: (gameId: number) => Promise<boolean>;
@ -77,6 +78,14 @@ declare global {
autoLaunch: (enabled: boolean) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<{ name: string; downloadCount: number }>;
addDownloadSource: (url: string) => Promise<DownloadSource>;
removeDownloadSource: (id: number) => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;

View file

@ -40,3 +40,7 @@ export const buildGameDetailsPath = (
const searchParams = new URLSearchParams({ title: game.title, ...params });
return `/game/${game.shop}/${game.objectID}?${searchParams.toString()}`;
};
export const numberFormatter = new Intl.NumberFormat("en-US", {
maximumSignificantDigits: 3,
});

View file

@ -99,12 +99,7 @@ export function Downloads() {
}
if (game.progress === 1) {
return (
<>
<p>{game.repack?.title}</p>
<p>{t("completed")}</p>
</>
);
return <p>{t("completed")}</p>;
}
if (game.status === "paused") {

View file

@ -21,6 +21,7 @@ export interface GameDetailsContext {
game: Game | null;
shopDetails: ShopDetails | null;
repacks: GameRepack[];
shop: GameShop;
gameTitle: string;
isGameRunning: boolean;
isLoading: boolean;
@ -35,6 +36,7 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
game: null,
shopDetails: null,
repacks: [],
shop: "steam",
gameTitle: "",
isGameRunning: false,
isLoading: false,
@ -92,7 +94,7 @@ export function GameDetailsContextProvider({
}, [updateGame, isGameDownloading, lastPacket?.game.status]);
useEffect(() => {
Promise.all([
Promise.allSettled([
window.electron.getGameShopDetails(
objectID!,
shop as GameShop,
@ -100,9 +102,12 @@ export function GameDetailsContextProvider({
),
window.electron.searchGameRepacks(gameTitle),
])
.then(([appDetails, repacks]) => {
if (appDetails) setGameDetails(appDetails);
setRepacks(repacks);
.then(([appDetailsResult, repacksResult]) => {
if (appDetailsResult.status === "fulfilled")
setGameDetails(appDetailsResult.value);
if (repacksResult.status === "fulfilled")
setRepacks(repacksResult.value);
})
.finally(() => {
setIsLoading(false);
@ -174,6 +179,7 @@ export function GameDetailsContextProvider({
value={{
game,
shopDetails,
shop: shop as GameShop,
repacks,
gameTitle,
isGameRunning,

View file

@ -46,7 +46,9 @@ export function DODIInstallationGuide({
flexDirection: "column",
}}
>
<p style={{ fontFamily: "Fira Sans", marginBottom: 8 }}>
<p
style={{ fontFamily: "Fira Sans", marginBottom: `${SPACING_UNIT}px` }}
>
<Trans i18nKey="dodi_installation_instruction" ns="game_details">
<ArrowUpIcon size={16} />
</Trans>

View file

@ -6,6 +6,7 @@ export const contentSidebar = style({
borderLeft: `solid 1px ${vars.color.border};`,
width: "100%",
height: "100%",
position: "relative",
"@media": {
"(min-width: 768px)": {
width: "100%",
@ -86,6 +87,14 @@ export const howLongToBeatCategorySkeleton = style({
height: "76px",
});
export const technicalDetailsContainer = style({
padding: `0 ${SPACING_UNIT * 2}px`,
color: vars.color.body,
userSelect: "text",
position: "absolute",
bottom: `${SPACING_UNIT}px`,
});
globalStyle(`${requirementsDetails} a`, {
display: "flex",
color: vars.color.body,

View file

@ -16,7 +16,8 @@ export function Sidebar() {
const [activeRequirement, setActiveRequirement] =
useState<keyof SteamAppDetails["pc_requirements"]>("minimum");
const { gameTitle, shopDetails, objectID } = useContext(gameDetailsContext);
const { gameTitle, shopDetails, shop, objectID } =
useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@ -74,6 +75,15 @@ export function Sidebar() {
}),
}}
/>
<div className={styles.technicalDetailsContainer}>
<p>
<small>shop: &quot;{shop}&quot;</small>
</p>
<p>
<small>objectID: &quot;{objectID}&quot;</small>
</p>
</div>
</aside>
);
}

View file

@ -1,15 +1,11 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const homeCategories = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const homeHeader = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
justifyContent: "space-between",
alignItems: "center",
});
export const content = style({

View file

@ -1,15 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components";
import {
Steam250Game,
type CatalogueCategory,
type CatalogueEntry,
} from "@types";
import type { Steam250Game, CatalogueEntry } from "@types";
import starsAnimation from "@renderer/assets/lottie/stars.json";
@ -18,8 +14,6 @@ import { vars } from "../../theme.css";
import Lottie from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";
const categories: CatalogueCategory[] = ["trending", "recently_added"];
export function Home() {
const { t } = useTranslation("home");
const navigate = useNavigate();
@ -27,22 +21,15 @@ export function Home() {
const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
const [searchParams] = useSearchParams();
const [catalogue, setCatalogue] = useState<CatalogueEntry[]>([]);
const [catalogue, setCatalogue] = useState<
Record<CatalogueCategory, CatalogueEntry[]>
>({
trending: [],
recently_added: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
const getCatalogue = useCallback(() => {
setIsLoading(true);
window.electron
.getCatalogue(category)
.getCatalogue()
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
setCatalogue(catalogue);
})
.catch(() => {})
.finally(() => {
@ -50,15 +37,6 @@ export function Home() {
});
}, []);
const currentCategory = searchParams.get("category") || categories[0];
const handleSelectCategory = (category: CatalogueCategory) => {
if (category !== currentCategory) {
getCatalogue(category);
navigate(`/?category=${category}`);
}
};
const getRandomGame = useCallback(() => {
window.electron.getRandomGame().then((game) => {
if (game) setRandomGame(game);
@ -80,9 +58,10 @@ export function Home() {
useEffect(() => {
setIsLoading(true);
getCatalogue(currentCategory as CatalogueCategory);
getCatalogue();
getRandomGame();
}, [getCatalogue, currentCategory, getRandomGame]);
}, [getCatalogue, getRandomGame]);
return (
<SkeletonTheme baseColor={vars.color.background} highlightColor="#444">
@ -92,17 +71,7 @@ export function Home() {
<Hero />
<section className={styles.homeHeader}>
<div className={styles.homeCategories}>
{categories.map((category) => (
<Button
key={category}
theme={currentCategory === category ? "primary" : "outline"}
onClick={() => handleSelectCategory(category)}
>
{t(category)}
</Button>
))}
</div>
<h2>{t("trending")}</h2>
<Button
onClick={handleRandomizerClick}
@ -120,14 +89,12 @@ export function Home() {
</Button>
</section>
<h2>{t(currentCategory)}</h2>
<section className={styles.cards}>
{isLoading
? Array.from({ length: 12 }).map((_, index) => (
<Skeleton key={index} className={styles.cardSkeleton} />
))
: catalogue[currentCategory as CatalogueCategory].map((result) => (
: catalogue.map((result) => (
<GameCard
key={result.objectID}
game={result}

View file

@ -0,0 +1,112 @@
import { Button, Modal, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-download-sources.css";
import { numberFormatter } from "@renderer/helpers";
interface AddDownloadSourceModalProps {
visible: boolean;
onClose: () => void;
onAddDownloadSource: () => void;
}
export function AddDownloadSourceModal({
visible,
onClose,
onAddDownloadSource,
}: AddDownloadSourceModalProps) {
const [value, setValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [validationResult, setValidationResult] = useState<{
name: string;
downloadCount: number;
} | null>(null);
const { t } = useTranslation("settings");
const handleValidateDownloadSource = async () => {
setIsLoading(true);
try {
const result = await window.electron.validateDownloadSource(value);
setValidationResult(result);
console.log(result);
} finally {
setIsLoading(false);
}
};
const handleAddDownloadSource = async () => {
await window.electron.addDownloadSource(value);
onClose();
onAddDownloadSource();
};
return (
<Modal
visible={visible}
title={t("add_download_source")}
description={t("add_download_source_description")}
onClose={onClose}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
minWidth: "500px",
}}
>
<div className={styles.downloadSourceField}>
<TextField
label={t("download_source_url")}
placeholder="Insert a valid JSON url"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<Button
type="button"
style={{ alignSelf: "flex-end" }}
onClick={handleValidateDownloadSource}
disabled={isLoading || !value}
>
{t("validate_download_source")}
</Button>
</div>
{validationResult && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: `${SPACING_UNIT * 3}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
}}
>
<h4>{validationResult?.name}</h4>
<small>
Found {numberFormatter.format(validationResult?.downloadCount)}{" "}
download options
</small>
</div>
<Button type="button" onClick={handleAddDownloadSource}>
Import
</Button>
</div>
)}
</div>
</Modal>
);
}

View file

@ -0,0 +1,24 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const downloadSourceField = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
});
export const downloadSourceItem = style({
display: "flex",
flexDirection: "column",
backgroundColor: vars.color.darkBackground,
borderRadius: "8px",
padding: `${SPACING_UNIT * 2}px`,
gap: `${SPACING_UNIT}px`,
border: `solid 1px ${vars.color.border}`,
});
export const downloadSourceItemHeader = style({
marginBottom: `${SPACING_UNIT}px`,
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT / 2}px`,
});

View file

@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { TextField, Button } from "@renderer/components";
import { useTranslation } from "react-i18next";
import * as styles from "./settings-download-sources.css";
import type { DownloadSource } from "@types";
import { NoEntryIcon, PlusCircleIcon } from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useToast } from "@renderer/hooks";
import { numberFormatter } from "@renderer/helpers";
export function SettingsDownloadSources() {
const [showAddDownloadSourceModal, setShowAddDownloadSourceModal] =
useState(false);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const { t } = useTranslation("settings");
const { showSuccessToast } = useToast();
const getDownloadSources = async () => {
return window.electron.getDownloadSources().then((sources) => {
setDownloadSources(sources);
});
};
useEffect(() => {
getDownloadSources();
}, []);
const handleRemoveSource = async (id: number) => {
await window.electron.removeDownloadSource(id);
showSuccessToast("Removed download source");
getDownloadSources();
};
const handleAddDownloadSource = async () => {
await getDownloadSources();
showSuccessToast("Download source successfully added");
};
return (
<>
<AddDownloadSourceModal
visible={showAddDownloadSourceModal}
onClose={() => setShowAddDownloadSourceModal(false)}
onAddDownloadSource={handleAddDownloadSource}
/>
<p style={{ fontFamily: '"Fira Sans"' }}>
{t("download_sources_description")}
</p>
{downloadSources.map((downloadSource) => (
<div key={downloadSource.id} className={styles.downloadSourceItem}>
<div className={styles.downloadSourceItemHeader}>
<h3>{downloadSource.name}</h3>
<small>
{t("download_options", {
count: downloadSource.repackCount,
countFormatted: numberFormatter.format(
downloadSource.repackCount
),
})}
</small>
</div>
<div className={styles.downloadSourceField}>
<TextField
label={t("download_source_url")}
value={downloadSource.url}
readOnly
disabled
/>
<Button
type="button"
theme="outline"
style={{ alignSelf: "flex-end" }}
onClick={() => handleRemoveSource(downloadSource.id)}
>
<NoEntryIcon />
{t("remove_download_source")}
</Button>
</div>
</div>
))}
<Button
type="button"
theme="outline"
style={{ alignSelf: "flex-start" }}
onClick={() => setShowAddDownloadSourceModal(true)}
>
<PlusCircleIcon />
{t("add_download_source")}
</Button>
</>
);
}

View file

@ -9,13 +9,19 @@ import { SettingsGeneral } from "./settings-general";
import { SettingsBehavior } from "./settings-behavior";
import { useAppDispatch } from "@renderer/hooks";
import { setUserPreferences } from "@renderer/features";
import { SettingsDownloadSources } from "./settings-download-sources";
export function Settings() {
const { t } = useTranslation("settings");
const dispatch = useAppDispatch();
const categories = [t("general"), t("behavior"), "Real-Debrid"];
const categories = [
t("general"),
t("behavior"),
t("download_sources"),
"Real-Debrid",
];
const [currentCategoryIndex, setCurrentCategoryIndex] = useState(0);
@ -41,6 +47,10 @@ export function Settings() {
);
}
if (currentCategoryIndex === 2) {
return <SettingsDownloadSources />;
}
return (
<SettingsRealDebrid updateUserPreferences={handleUpdateUserPreferences} />
);

View file

@ -6,10 +6,10 @@ interface BinaryNotFoundModalProps {
onClose: () => void;
}
export const BinaryNotFoundModal = ({
export function BinaryNotFoundModal({
visible,
onClose,
}: BinaryNotFoundModalProps) => {
}: BinaryNotFoundModalProps) {
const { t } = useTranslation("binary_not_found_modal");
return (
@ -22,4 +22,4 @@ export const BinaryNotFoundModal = ({
{t("instructions")}
</Modal>
);
};
}