mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: adding cloud sync
This commit is contained in:
parent
d88e06e289
commit
e64a414309
33 changed files with 1352 additions and 84 deletions
1
src/renderer/src/assets/lottie/cloud.json
Normal file
1
src/renderer/src/assets/lottie/cloud.json
Normal file
File diff suppressed because one or more lines are too long
187
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
187
src/renderer/src/context/cloud-sync/cloud-sync.context.tsx
Normal file
|
@ -0,0 +1,187 @@
|
|||
import { gameBackupsTable } from "@renderer/dexie";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import type { LudusaviBackup, GameArtifact, GameShop } from "@types";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export enum CloudSyncState {
|
||||
New,
|
||||
Different,
|
||||
Same,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface CloudSyncContext {
|
||||
backupPreview: LudusaviBackup | null;
|
||||
artifacts: GameArtifact[];
|
||||
showCloudSyncModal: boolean;
|
||||
supportsCloudSync: boolean | null;
|
||||
backupState: CloudSyncState;
|
||||
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
|
||||
uploadSaveGame: () => Promise<void>;
|
||||
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
|
||||
restoringBackup: boolean;
|
||||
uploadingBackup: boolean;
|
||||
}
|
||||
|
||||
export const cloudSyncContext = createContext<CloudSyncContext>({
|
||||
backupPreview: null,
|
||||
showCloudSyncModal: false,
|
||||
supportsCloudSync: null,
|
||||
backupState: CloudSyncState.Unknown,
|
||||
setShowCloudSyncModal: () => {},
|
||||
downloadGameArtifact: async () => {},
|
||||
uploadSaveGame: async () => {},
|
||||
artifacts: [],
|
||||
deleteGameArtifact: async () => ({ ok: false }),
|
||||
restoringBackup: false,
|
||||
uploadingBackup: false,
|
||||
});
|
||||
|
||||
const { Provider } = cloudSyncContext;
|
||||
export const { Consumer: CloudSyncContextConsumer } = cloudSyncContext;
|
||||
|
||||
export interface CloudSyncContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
objectId: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
|
||||
export function CloudSyncContextProvider({
|
||||
children,
|
||||
objectId,
|
||||
shop,
|
||||
}: CloudSyncContextProviderProps) {
|
||||
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
const [artifacts, setArtifacts] = useState<GameArtifact[]>([]);
|
||||
const [showCloudSyncModal, setShowCloudSyncModal] = useState(false);
|
||||
const [backupPreview, setBackupPreview] = useState<LudusaviBackup | null>(
|
||||
null
|
||||
);
|
||||
const [restoringBackup, setRestoringBackup] = useState(false);
|
||||
const [uploadingBackup, setUploadingBackup] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const downloadGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
setRestoringBackup(true);
|
||||
window.electron.downloadGameArtifact(objectId, shop, gameArtifactId);
|
||||
},
|
||||
[objectId, shop]
|
||||
);
|
||||
|
||||
const getGameBackupPreview = useCallback(async () => {
|
||||
window.electron.getGameArtifacts(objectId, shop).then((results) => {
|
||||
setArtifacts(results);
|
||||
});
|
||||
|
||||
window.electron.getGameBackupPreview(objectId, shop).then((preview) => {
|
||||
if (preview && Object.keys(preview.games).length) {
|
||||
setBackupPreview(preview);
|
||||
}
|
||||
});
|
||||
}, [objectId, shop]);
|
||||
|
||||
const uploadSaveGame = useCallback(async () => {
|
||||
setUploadingBackup(true);
|
||||
window.electron.uploadSaveGame(objectId, shop);
|
||||
}, [objectId, shop]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeUploadCompleteListener = window.electron.onUploadComplete(
|
||||
objectId,
|
||||
shop,
|
||||
() => {
|
||||
showSuccessToast("backup_uploaded");
|
||||
|
||||
setUploadingBackup(false);
|
||||
gameBackupsTable.add({
|
||||
objectId,
|
||||
shop,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
getGameBackupPreview();
|
||||
}
|
||||
);
|
||||
|
||||
const removeDownloadCompleteListener = window.electron.onDownloadComplete(
|
||||
objectId,
|
||||
shop,
|
||||
() => {
|
||||
showSuccessToast("backup_restored");
|
||||
|
||||
setRestoringBackup(false);
|
||||
getGameBackupPreview();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
removeUploadCompleteListener();
|
||||
removeDownloadCompleteListener();
|
||||
};
|
||||
}, [objectId, shop, showSuccessToast, getGameBackupPreview]);
|
||||
|
||||
const deleteGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
return window.electron.deleteGameArtifact(gameArtifactId).then(() => {
|
||||
getGameBackupPreview();
|
||||
return { ok: true };
|
||||
});
|
||||
},
|
||||
[getGameBackupPreview]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
getGameBackupPreview();
|
||||
|
||||
window.electron.checkGameCloudSyncSupport(objectId, shop).then((result) => {
|
||||
setSupportsCloudSync(result);
|
||||
});
|
||||
}, [objectId, shop, getGameBackupPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCloudSyncModal) {
|
||||
getGameBackupPreview();
|
||||
}
|
||||
}, [getGameBackupPreview, showCloudSyncModal]);
|
||||
|
||||
const backupState = useMemo(() => {
|
||||
if (!backupPreview) return CloudSyncState.Unknown;
|
||||
if (backupPreview.overall.changedGames.new) return CloudSyncState.New;
|
||||
if (backupPreview.overall.changedGames.different)
|
||||
return CloudSyncState.Different;
|
||||
if (backupPreview.overall.changedGames.same) return CloudSyncState.Same;
|
||||
|
||||
return CloudSyncState.Unknown;
|
||||
}, [backupPreview]);
|
||||
|
||||
return (
|
||||
<Provider
|
||||
value={{
|
||||
supportsCloudSync,
|
||||
backupPreview,
|
||||
showCloudSyncModal,
|
||||
artifacts,
|
||||
backupState,
|
||||
restoringBackup,
|
||||
uploadingBackup,
|
||||
setShowCloudSyncModal,
|
||||
uploadSaveGame,
|
||||
downloadGameArtifact,
|
||||
deleteGameArtifact,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
|
@ -5,7 +5,6 @@ import {
|
|||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { getSteamLanguage } from "@renderer/helpers";
|
||||
|
@ -51,13 +50,17 @@ export const { Consumer: GameDetailsContextConsumer } = gameDetailsContext;
|
|||
|
||||
export interface GameDetailsContextProps {
|
||||
children: React.ReactNode;
|
||||
objectId: string;
|
||||
gameTitle: string;
|
||||
shop: GameShop;
|
||||
}
|
||||
|
||||
export function GameDetailsContextProvider({
|
||||
children,
|
||||
objectId,
|
||||
gameTitle,
|
||||
shop,
|
||||
}: GameDetailsContextProps) {
|
||||
const { objectID, shop } = useParams();
|
||||
|
||||
const [shopDetails, setShopDetails] = useState<ShopDetails | null>(null);
|
||||
const [game, setGame] = useState<Game | null>(null);
|
||||
const [hasNSFWContentBlocked, setHasNSFWContentBlocked] = useState(false);
|
||||
|
@ -72,10 +75,6 @@ export function GameDetailsContextProvider({
|
|||
|
||||
const [repacks, setRepacks] = useState<GameRepack[]>([]);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const gameTitle = searchParams.get("title")!;
|
||||
|
||||
const { searchRepacks, isIndexingRepacks } = useContext(repacksContext);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -98,9 +97,9 @@ export function GameDetailsContextProvider({
|
|||
|
||||
const updateGame = useCallback(async () => {
|
||||
return window.electron
|
||||
.getGameByObjectID(objectID!)
|
||||
.getGameByObjectID(objectId!)
|
||||
.then((result) => setGame(result));
|
||||
}, [setGame, objectID]);
|
||||
}, [setGame, objectId]);
|
||||
|
||||
const isGameDownloading = lastPacket?.game.id === game?.id;
|
||||
|
||||
|
@ -111,7 +110,7 @@ export function GameDetailsContextProvider({
|
|||
useEffect(() => {
|
||||
window.electron
|
||||
.getGameShopDetails(
|
||||
objectID!,
|
||||
objectId!,
|
||||
shop as GameShop,
|
||||
getSteamLanguage(i18n.language)
|
||||
)
|
||||
|
@ -130,12 +129,12 @@ export function GameDetailsContextProvider({
|
|||
setIsLoading(false);
|
||||
});
|
||||
|
||||
window.electron.getGameStats(objectID!, shop as GameShop).then((result) => {
|
||||
window.electron.getGameStats(objectId!, shop as GameShop).then((result) => {
|
||||
setStats(result);
|
||||
});
|
||||
|
||||
updateGame();
|
||||
}, [updateGame, dispatch, gameTitle, objectID, shop, i18n.language]);
|
||||
}, [updateGame, dispatch, gameTitle, objectId, shop, i18n.language]);
|
||||
|
||||
useEffect(() => {
|
||||
setShopDetails(null);
|
||||
|
@ -143,7 +142,7 @@ export function GameDetailsContextProvider({
|
|||
setIsLoading(true);
|
||||
setisGameRunning(false);
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectID, gameTitle, dispatch]);
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.onGamesRunning((gamesIds) => {
|
||||
|
@ -200,7 +199,7 @@ export function GameDetailsContextProvider({
|
|||
gameTitle,
|
||||
isGameRunning,
|
||||
isLoading,
|
||||
objectID,
|
||||
objectID: objectId,
|
||||
gameColor,
|
||||
showGameOptionsModal,
|
||||
showRepacksModal,
|
||||
|
|
|
@ -2,3 +2,4 @@ export * from "./game-details/game-details.context";
|
|||
export * from "./settings/settings.context";
|
||||
export * from "./user-profile/user-profile.context";
|
||||
export * from "./repacks/repacks.context";
|
||||
export * from "./cloud-sync/cloud-sync.context";
|
||||
|
|
33
src/renderer/src/declaration.d.ts
vendored
33
src/renderer/src/declaration.d.ts
vendored
|
@ -26,6 +26,8 @@ import type {
|
|||
UserDetails,
|
||||
FriendRequestSync,
|
||||
DownloadSourceValidationResult,
|
||||
GameArtifact,
|
||||
LudusaviBackup,
|
||||
} from "@types";
|
||||
import type { DiskSpace } from "check-disk-space";
|
||||
|
||||
|
@ -113,6 +115,37 @@ declare global {
|
|||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
|
||||
|
||||
/* Cloud sync */
|
||||
uploadSaveGame: (objectId: string, shop: GameShop) => Promise<void>;
|
||||
downloadGameArtifact: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
gameArtifactId: string
|
||||
) => Promise<void>;
|
||||
getGameArtifacts: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<GameArtifact[]>;
|
||||
getGameBackupPreview: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<LudusaviBackup | null>;
|
||||
checkGameCloudSyncSupport: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<boolean>;
|
||||
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
|
||||
onDownloadComplete: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: () => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
onUploadComplete: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
cb: () => void
|
||||
) => () => Electron.IpcRenderer;
|
||||
|
||||
/* Misc */
|
||||
openExternal: (src: string) => Promise<void>;
|
||||
getVersion: () => Promise<string>;
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
import { GameShop } from "@types";
|
||||
import { Dexie } from "dexie";
|
||||
|
||||
export interface GameBackup {
|
||||
id?: number;
|
||||
shop: GameShop;
|
||||
objectId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(1).stores({
|
||||
db.version(3).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
|
||||
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
|
||||
gameBackups: `++id, [shop+objectId], createdAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
export const repacksTable = db.table("repacks");
|
||||
export const gameBackupsTable = db.table<GameBackup>("gameBackups");
|
||||
|
||||
db.open();
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
import { SPACING_UNIT, vars } from "../../../theme.css";
|
||||
|
||||
export const artifacts = style({
|
||||
display: "flex",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
flexDirection: "column",
|
||||
listStyle: "none",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
});
|
||||
|
||||
export const artifactButton = style({
|
||||
display: "flex",
|
||||
textAlign: "left",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.body,
|
||||
padding: `${SPACING_UNIT * 2}px`,
|
||||
backgroundColor: vars.color.darkBackground,
|
||||
border: `1px solid ${vars.color.border}`,
|
||||
borderRadius: "4px",
|
||||
justifyContent: "space-between",
|
||||
});
|
|
@ -0,0 +1,178 @@
|
|||
import { Button, Modal, ModalProps } from "@renderer/components";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import * as styles from "./cloud-sync-modal.css";
|
||||
import { formatBytes } from "@shared";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
ClockIcon,
|
||||
DeviceDesktopIcon,
|
||||
DownloadIcon,
|
||||
SyncIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { GameBackup, gameBackupsTable } from "@renderer/dexie";
|
||||
|
||||
export interface CloudSyncModalProps
|
||||
extends Omit<ModalProps, "children" | "title"> {}
|
||||
|
||||
export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
|
||||
const [deletingArtifact, setDeletingArtifact] = useState(false);
|
||||
const [lastBackup, setLastBackup] = useState<GameBackup | null>(null);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
backupPreview,
|
||||
uploadingBackup,
|
||||
restoringBackup,
|
||||
uploadSaveGame,
|
||||
downloadGameArtifact,
|
||||
deleteGameArtifact,
|
||||
} = useContext(cloudSyncContext);
|
||||
|
||||
const { objectID, shop, gameTitle } = useContext(gameDetailsContext);
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const handleDeleteArtifactClick = async (gameArtifactId: string) => {
|
||||
setDeletingArtifact(true);
|
||||
|
||||
try {
|
||||
await deleteGameArtifact(gameArtifactId);
|
||||
|
||||
showSuccessToast("backup_successfully_deleted");
|
||||
} catch (err) {
|
||||
showErrorToast("backup_deletion_failed");
|
||||
} finally {
|
||||
setDeletingArtifact(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
gameBackupsTable
|
||||
.where({ shop: shop, objectId: objectID })
|
||||
.last()
|
||||
.then((lastBackup) => setLastBackup(lastBackup || null));
|
||||
}, [backupPreview, objectID, shop]);
|
||||
|
||||
const backupStateLabel = useMemo(() => {
|
||||
if (uploadingBackup) {
|
||||
return (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<SyncIcon />
|
||||
creating_backup
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (restoringBackup) {
|
||||
return (
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<SyncIcon />
|
||||
restoring_backup
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastBackup) {
|
||||
return (
|
||||
<p style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<CheckCircleFillIcon />
|
||||
Último backup em {format(lastBackup.createdAt, "dd/MM/yyyy HH:mm")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return "no_backups";
|
||||
}, [uploadingBackup, lastBackup, restoringBackup]);
|
||||
|
||||
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title="cloud_sync"
|
||||
description="cloud_sync_description"
|
||||
onClose={onClose}
|
||||
large
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
|
||||
<h2>{gameTitle}</h2>
|
||||
{backupStateLabel}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={uploadSaveGame}
|
||||
disabled={disableActions}
|
||||
>
|
||||
<UploadIcon />
|
||||
create_backup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h2 style={{ marginBottom: 16 }}>backups</h2>
|
||||
|
||||
<ul className={styles.artifacts}>
|
||||
{artifacts.map((artifact) => (
|
||||
<li key={artifact.id} className={styles.artifactButton}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<h3>Backup do dia {format(artifact.createdAt, "dd/MM")}</h3>
|
||||
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
|
||||
</div>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<DeviceDesktopIcon size={14} />
|
||||
{artifact.hostname}
|
||||
</span>
|
||||
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<ClockIcon size={14} />
|
||||
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => downloadGameArtifact(artifact.id)}
|
||||
disabled={disableActions}
|
||||
>
|
||||
<DownloadIcon />
|
||||
install_artifact
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleDeleteArtifactClick(artifact.id)}
|
||||
theme="danger"
|
||||
disabled={disableActions}
|
||||
>
|
||||
<TrashIcon />
|
||||
delete_artifact
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -9,8 +9,11 @@ import { Sidebar } from "./sidebar/sidebar";
|
|||
|
||||
import * as styles from "./game-details.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { steamUrlBuilder } from "@shared";
|
||||
import Lottie from "lottie-react";
|
||||
|
||||
import downloadingAnimation from "@renderer/assets/lottie/cloud.json";
|
||||
|
||||
const HERO_ANIMATION_THRESHOLD = 25;
|
||||
|
||||
|
@ -30,6 +33,9 @@ export function GameDetailsContent() {
|
|||
hasNSFWContentBlocked,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const { supportsCloudSync, setShowCloudSyncModal } =
|
||||
useContext(cloudSyncContext);
|
||||
|
||||
const [backdropOpactiy, setBackdropOpacity] = useState(1);
|
||||
|
||||
const handleHeroLoad = async () => {
|
||||
|
@ -102,6 +108,33 @@ export function GameDetailsContent() {
|
|||
className={styles.gameLogo}
|
||||
alt={game?.title}
|
||||
/>
|
||||
|
||||
{supportsCloudSync && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cloudSyncButton}
|
||||
onClick={() => setShowCloudSyncModal(true)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 16 + 4,
|
||||
height: 16,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Lottie
|
||||
animationData={downloadingAnimation}
|
||||
loop
|
||||
autoplay
|
||||
style={{ width: 26, position: "absolute", top: -3 }}
|
||||
/>
|
||||
</div>
|
||||
cloud_sync
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -6,8 +6,8 @@ import { recipe } from "@vanilla-extract/recipes";
|
|||
export const HERO_HEIGHT = 300;
|
||||
|
||||
export const slideIn = keyframes({
|
||||
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)` },
|
||||
"100%": { transform: "translateY(0)" },
|
||||
"0%": { transform: `translateY(${40 + SPACING_UNIT * 2}px)`, opacity: "0px" },
|
||||
"100%": { transform: "translateY(0)", opacity: "1" },
|
||||
});
|
||||
|
||||
export const wrapper = recipe({
|
||||
|
@ -49,6 +49,8 @@ export const heroContent = style({
|
|||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
});
|
||||
|
||||
export const heroLogoBackdrop = style({
|
||||
|
@ -200,3 +202,33 @@ globalStyle(`${description} img`, {
|
|||
globalStyle(`${description} a`, {
|
||||
color: vars.color.body,
|
||||
});
|
||||
|
||||
export const cloudSyncButton = style({
|
||||
padding: `${SPACING_UNIT * 1.5}px ${SPACING_UNIT * 2}px`,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
backdropFilter: "blur(20px)",
|
||||
borderRadius: "8px",
|
||||
transition: "all ease 0.2s",
|
||||
cursor: "pointer",
|
||||
minHeight: "40px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: `${SPACING_UNIT}px`,
|
||||
color: vars.color.muted,
|
||||
fontSize: "14px",
|
||||
border: `solid 1px ${vars.color.border}`,
|
||||
boxShadow: "0px 0px 10px 0px rgba(0, 0, 0, 0.8)",
|
||||
animation: `${slideIn} 0.2s cubic-bezier(0.33, 1, 0.68, 1) 0s 1 normal none running`,
|
||||
animationDuration: "0.3s",
|
||||
":active": {
|
||||
opacity: "0.9",
|
||||
},
|
||||
":disabled": {
|
||||
opacity: vars.opacity.disabled,
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
":hover": {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
});
|
||||
|
|
|
@ -18,21 +18,25 @@ import { vars } from "@renderer/theme.css";
|
|||
|
||||
import { GameDetailsContent } from "./game-details-content";
|
||||
import {
|
||||
CloudSyncContextConsumer,
|
||||
CloudSyncContextProvider,
|
||||
GameDetailsContextConsumer,
|
||||
GameDetailsContextProvider,
|
||||
} from "@renderer/context";
|
||||
import { useDownload } from "@renderer/hooks";
|
||||
import { GameOptionsModal, RepacksModal } from "./modals";
|
||||
import { Downloader, getDownloadersForUri } from "@shared";
|
||||
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
|
||||
|
||||
export function GameDetails() {
|
||||
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
|
||||
const [randomizerLocked, setRandomizerLocked] = useState(false);
|
||||
|
||||
const { objectID } = useParams();
|
||||
const { objectID, shop } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const fromRandomizer = searchParams.get("fromRandomizer");
|
||||
const gameTitle = searchParams.get("title");
|
||||
|
||||
const { startDownload } = useDownload();
|
||||
|
||||
|
@ -74,7 +78,11 @@ export function GameDetails() {
|
|||
repack.uris.find((uri) => getDownloadersForUri(uri).includes(downloader))!;
|
||||
|
||||
return (
|
||||
<GameDetailsContextProvider>
|
||||
<GameDetailsContextProvider
|
||||
gameTitle={gameTitle!}
|
||||
shop={shop! as GameShop}
|
||||
objectId={objectID!}
|
||||
>
|
||||
<GameDetailsContextConsumer>
|
||||
{({
|
||||
isLoading,
|
||||
|
@ -115,64 +123,80 @@ export function GameDetails() {
|
|||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.background}
|
||||
highlightColor="#444"
|
||||
<CloudSyncContextProvider
|
||||
objectId={objectID!}
|
||||
shop={shop! as GameShop}
|
||||
>
|
||||
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
|
||||
<CloudSyncContextConsumer>
|
||||
{({ showCloudSyncModal, setShowCloudSyncModal }) => (
|
||||
<CloudSyncModal
|
||||
onClose={() => setShowCloudSyncModal(false)}
|
||||
visible={showCloudSyncModal}
|
||||
/>
|
||||
)}
|
||||
</CloudSyncContextConsumer>
|
||||
|
||||
<RepacksModal
|
||||
visible={showRepacksModal}
|
||||
startDownload={handleStartDownload}
|
||||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
<SkeletonTheme
|
||||
baseColor={vars.color.background}
|
||||
highlightColor="#444"
|
||||
>
|
||||
{isLoading ? <GameDetailsSkeleton /> : <GameDetailsContent />}
|
||||
|
||||
<ConfirmationModal
|
||||
visible={hasNSFWContentBlocked}
|
||||
onClose={handleNSFWContentRefuse}
|
||||
title={t("nsfw_content_title")}
|
||||
descriptionText={t("nsfw_content_description", {
|
||||
title: gameTitle,
|
||||
})}
|
||||
confirmButtonLabel={t("allow_nsfw_content")}
|
||||
cancelButtonLabel={t("refuse_nsfw_content")}
|
||||
onConfirm={() => setHasNSFWContentBlocked(false)}
|
||||
clickOutsideToClose={false}
|
||||
/>
|
||||
|
||||
{game && (
|
||||
<GameOptionsModal
|
||||
visible={showGameOptionsModal}
|
||||
game={game}
|
||||
onClose={() => {
|
||||
setShowGameOptionsModal(false);
|
||||
}}
|
||||
<RepacksModal
|
||||
visible={showRepacksModal}
|
||||
startDownload={handleStartDownload}
|
||||
onClose={() => setShowRepacksModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fromRandomizer && (
|
||||
<Button
|
||||
className={styles.randomizerButton}
|
||||
onClick={handleRandomizerClick}
|
||||
theme="outline"
|
||||
disabled={!randomGame || randomizerLocked}
|
||||
>
|
||||
<div style={{ width: 16, height: 16, position: "relative" }}>
|
||||
<Lottie
|
||||
animationData={starsAnimation}
|
||||
style={{
|
||||
width: 70,
|
||||
position: "absolute",
|
||||
top: -28,
|
||||
left: -27,
|
||||
}}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
{t("next_suggestion")}
|
||||
</Button>
|
||||
)}
|
||||
</SkeletonTheme>
|
||||
<ConfirmationModal
|
||||
visible={hasNSFWContentBlocked}
|
||||
onClose={handleNSFWContentRefuse}
|
||||
title={t("nsfw_content_title")}
|
||||
descriptionText={t("nsfw_content_description", {
|
||||
title: gameTitle,
|
||||
})}
|
||||
confirmButtonLabel={t("allow_nsfw_content")}
|
||||
cancelButtonLabel={t("refuse_nsfw_content")}
|
||||
onConfirm={() => setHasNSFWContentBlocked(false)}
|
||||
clickOutsideToClose={false}
|
||||
/>
|
||||
|
||||
{game && (
|
||||
<GameOptionsModal
|
||||
visible={showGameOptionsModal}
|
||||
game={game}
|
||||
onClose={() => {
|
||||
setShowGameOptionsModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fromRandomizer && (
|
||||
<Button
|
||||
className={styles.randomizerButton}
|
||||
onClick={handleRandomizerClick}
|
||||
theme="outline"
|
||||
disabled={!randomGame || randomizerLocked}
|
||||
>
|
||||
<div
|
||||
style={{ width: 16, height: 16, position: "relative" }}
|
||||
>
|
||||
<Lottie
|
||||
animationData={starsAnimation}
|
||||
style={{
|
||||
width: 70,
|
||||
position: "absolute",
|
||||
top: -28,
|
||||
left: -27,
|
||||
}}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
{t("next_suggestion")}
|
||||
</Button>
|
||||
)}
|
||||
</SkeletonTheme>
|
||||
</CloudSyncContextProvider>
|
||||
);
|
||||
}}
|
||||
</GameDetailsContextConsumer>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue