docs: moving readme

This commit is contained in:
Chubby Granny Chaser 2024-09-27 23:19:39 +01:00
commit 55a92fd68a
No known key found for this signature in database
64 changed files with 771 additions and 549 deletions

View file

@ -26,7 +26,7 @@ import {
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
// import { migrationWorker } from "./workers";
import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context";
export interface AppProps {
@ -39,6 +39,8 @@ export function App() {
const { t } = useTranslation("app");
const downloadSourceMigrationLock = useRef(false);
const { clearDownload, setLastPacket } = useDownload();
const { indexRepacks } = useContext(repacksContext);
@ -211,22 +213,46 @@ export function App() {
}, [dispatch, draggingDisabled]);
useEffect(() => {
// window.electron.getRepacks().then((repacks) => {
// migrationWorker.postMessage(["MIGRATE_REPACKS", repacks]);
// });
// window.electron.getDownloadSources().then((downloadSources) => {
// migrationWorker.postMessage([
// "MIGRATE_DOWNLOAD_SOURCES",
// downloadSources,
// ]);
// });
// migrationWorker.onmessage = (
// event: MessageEvent<"MIGRATE_REPACKS_COMPLETE">
// ) => {
// if (event.data === "MIGRATE_REPACKS_COMPLETE") {
// indexRepacks();
// }
// };
if (downloadSourceMigrationLock.current) return;
downloadSourceMigrationLock.current = true;
window.electron.getDownloadSources().then(async (downloadSources) => {
if (!downloadSources.length) {
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
channel.onmessage = (event: MessageEvent<number>) => {
const newRepacksCount = event.data;
window.electron.publishNewRepacksNotification(newRepacksCount);
};
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
}
for (const downloadSource of downloadSources) {
const channel = new BroadcastChannel(
`download_sources:import:${downloadSource.url}`
);
await new Promise((resolve) => {
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
downloadSource.url,
]);
channel.onmessage = () => {
window.electron.deleteDownloadSource(downloadSource.id).then(() => {
resolve(true);
});
indexRepacks();
channel.close();
};
}).catch(() => channel.close());
}
downloadSourceMigrationLock.current = false;
});
}, [indexRepacks]);
const handleToastClose = useCallback(() => {

View file

@ -8,6 +8,7 @@ import React, {
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
export enum CloudSyncState {
New,
@ -20,12 +21,14 @@ export interface CloudSyncContext {
backupPreview: LudusaviBackup | null;
artifacts: GameArtifact[];
showCloudSyncModal: boolean;
showCloudSyncFilesModal: boolean;
supportsCloudSync: boolean | null;
backupState: CloudSyncState;
setShowCloudSyncModal: React.Dispatch<React.SetStateAction<boolean>>;
downloadGameArtifact: (gameArtifactId: string) => Promise<void>;
uploadSaveGame: () => Promise<void>;
deleteGameArtifact: (gameArtifactId: string) => Promise<void>;
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
restoringBackup: boolean;
uploadingBackup: boolean;
}
@ -40,6 +43,8 @@ export const cloudSyncContext = createContext<CloudSyncContext>({
uploadSaveGame: async () => {},
artifacts: [],
deleteGameArtifact: async () => {},
showCloudSyncFilesModal: false,
setShowCloudSyncFilesModal: () => {},
restoringBackup: false,
uploadingBackup: false,
});
@ -58,6 +63,8 @@ export function CloudSyncContextProvider({
objectId,
shop,
}: CloudSyncContextProviderProps) {
const { t } = useTranslation("game_details");
const [supportsCloudSync, setSupportsCloudSync] = useState<boolean | null>(
null
);
@ -68,6 +75,7 @@ export function CloudSyncContextProvider({
);
const [restoringBackup, setRestoringBackup] = useState(false);
const [uploadingBackup, setUploadingBackup] = useState(false);
const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false);
const { showSuccessToast } = useToast();
@ -101,7 +109,7 @@ export function CloudSyncContextProvider({
objectId,
shop,
() => {
showSuccessToast("backup_uploaded");
showSuccessToast(t("backup_uploaded"));
setUploadingBackup(false);
gameBackupsTable.add({
@ -114,22 +122,19 @@ export function CloudSyncContextProvider({
}
);
const removeDownloadCompleteListener = window.electron.onDownloadComplete(
objectId,
shop,
() => {
showSuccessToast("backup_restored");
const removeDownloadCompleteListener =
window.electron.onBackupDownloadComplete(objectId, shop, () => {
showSuccessToast(t("backup_restored"));
setRestoringBackup(false);
getGameBackupPreview();
}
);
});
return () => {
removeUploadCompleteListener();
removeDownloadCompleteListener();
};
}, [objectId, shop, showSuccessToast, getGameBackupPreview]);
}, [objectId, shop, showSuccessToast, t, getGameBackupPreview]);
const deleteGameArtifact = useCallback(
async (gameArtifactId: string) => {
@ -181,10 +186,12 @@ export function CloudSyncContextProvider({
backupState,
restoringBackup,
uploadingBackup,
showCloudSyncFilesModal,
setShowCloudSyncModal,
uploadSaveGame,
downloadGameArtifact,
deleteGameArtifact,
setShowCloudSyncFilesModal,
}}
>
{children}

View file

@ -33,6 +33,7 @@ export function RepacksContextProvider({ children }: RepacksContextProps) {
const channel = new BroadcastChannel(`repacks:search:${channelId}`);
channel.onmessage = (event: MessageEvent<GameRepack[]>) => {
resolve(event.data);
channel.close();
};
return [];

View file

@ -25,10 +25,10 @@ import type {
UserStats,
UserDetails,
FriendRequestSync,
DownloadSourceValidationResult,
GameArtifact,
LudusaviBackup,
} from "@types";
import type { AxiosProgressEvent } from "axios";
import type { DiskSpace } from "check-disk-space";
declare global {
@ -68,8 +68,6 @@ declare global {
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
/* Meant for Dexie migration */
getRepacks: () => Promise<GameRepack[]>;
/* Library */
addGameToLibrary: (
@ -107,10 +105,7 @@ declare global {
/* Download sources */
getDownloadSources: () => Promise<DownloadSource[]>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: (downloadSources: DownloadSource[]) => Promise<void>;
deleteDownloadSource: (id: number) => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskSpace>;
@ -135,7 +130,7 @@ declare global {
shop: GameShop
) => Promise<boolean>;
deleteGameArtifact: (gameArtifactId: string) => Promise<{ ok: boolean }>;
onDownloadComplete: (
onBackupDownloadComplete: (
objectId: string,
shop: GameShop,
cb: () => void
@ -145,6 +140,11 @@ declare global {
shop: GameShop,
cb: () => void
) => () => Electron.IpcRenderer;
onBackupDownloadProgress: (
objectId: string,
shop: GameShop,
cb: (progress: AxiosProgressEvent) => void
) => () => Electron.IpcRenderer;
/* Misc */
openExternal: (src: string) => Promise<void>;
@ -205,6 +205,9 @@ declare global {
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
}
interface Window {

View file

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

View file

@ -0,0 +1,77 @@
import { Button, Modal, ModalProps, TextField } from "@renderer/components";
import { useContext, useMemo } from "react";
import { cloudSyncContext } from "@renderer/context";
import { useTranslation } from "react-i18next";
import { CheckCircleFillIcon } from "@primer/octicons-react";
export interface CloudSyncFilesModalProps
extends Omit<ModalProps, "children" | "title"> {}
export function CloudSyncFilesModal({
visible,
onClose,
}: CloudSyncFilesModalProps) {
const { t } = useTranslation("game_details");
const { backupPreview } = useContext(cloudSyncContext);
const files = useMemo(() => {
if (!backupPreview) {
return [];
}
const [game] = Object.values(backupPreview.games);
const entries = Object.entries(game.files);
return entries.map(([key, value]) => {
return { path: key, ...value };
});
}, [backupPreview]);
return (
<Modal
visible={visible}
title="Gerenciar arquivos"
description="Escolha quais diretórios serão sincronizados"
onClose={onClose}
>
{/* <div className={styles.downloaders}>
{["AUTOMATIC", "CUSTOM"].map((downloader) => (
<Button
key={downloader}
className={styles.downloaderOption}
theme={selectedDownloader === downloader ? "primary" : "outline"}
disabled={
downloader === Downloader.RealDebrid &&
!userPreferences?.realDebridApiToken
}
onClick={() => setSelectedDownloader(downloader)}
>
{selectedDownloader === downloader && (
<CheckCircleFillIcon className={styles.downloaderIcon} />
)}
{DOWNLOADER_NAME[downloader]}
</Button>
))}
</div> */}
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
gap: 16,
display: "flex",
flexDirection: "column",
}}
>
{files.map((file) => (
<li key={file.path}>
<TextField value={file.path} readOnly />
</li>
))}
</ul>
</Modal>
);
}

View file

@ -1,7 +1,14 @@
import { style } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../../theme.css";
export const rotate = keyframes({
"0%": { transform: "rotate(0deg)" },
"100%": {
transform: "rotate(360deg)",
},
});
export const artifacts = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
@ -24,3 +31,10 @@ export const artifactButton = style({
borderRadius: "4px",
justifyContent: "space-between",
});
export const syncIcon = style({
animationName: rotate,
animationDuration: "1s",
animationIterationCount: "infinite",
animationTimingFunction: "linear",
});

View file

@ -9,7 +9,7 @@ import {
CheckCircleFillIcon,
ClockIcon,
DeviceDesktopIcon,
DownloadIcon,
HistoryIcon,
SyncIcon,
TrashIcon,
UploadIcon,
@ -17,6 +17,9 @@ import {
import { useToast } from "@renderer/hooks";
import { GameBackup, gameBackupsTable } from "@renderer/dexie";
import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export interface CloudSyncModalProps
extends Omit<ModalProps, "children" | "title"> {}
@ -24,6 +27,8 @@ export interface CloudSyncModalProps
export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const [deletingArtifact, setDeletingArtifact] = useState(false);
const [lastBackup, setLastBackup] = useState<GameBackup | null>(null);
const [backupDownloadProgress, setBackupDownloadProgress] =
useState<AxiosProgressEvent | null>(null);
const { t } = useTranslation("game_details");
@ -35,6 +40,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
uploadSaveGame,
downloadGameArtifact,
deleteGameArtifact,
setShowCloudSyncFilesModal,
} = useContext(cloudSyncContext);
const { objectID, shop, gameTitle } = useContext(gameDetailsContext);
@ -60,13 +66,31 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
.where({ shop: shop, objectId: objectID })
.last()
.then((lastBackup) => setLastBackup(lastBackup || null));
const removeBackupDownloadProgressListener =
window.electron.onBackupDownloadProgress(
objectID!,
shop,
(progressEvent) => {
setBackupDownloadProgress(progressEvent);
}
);
return () => {
removeBackupDownloadProgressListener();
};
}, [backupPreview, objectID, shop]);
const handleBackupInstallClick = async (artifactId: string) => {
setBackupDownloadProgress(null);
downloadGameArtifact(artifactId);
};
const backupStateLabel = useMemo(() => {
if (uploadingBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon />
<SyncIcon className={styles.syncIcon} />
{t("uploading_backup")}
</span>
);
@ -75,8 +99,12 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (restoringBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<SyncIcon />
{t("restoring_backup")}
<SyncIcon className={styles.syncIcon} />
{t("restoring_backup", {
progress: formatDownloadProgress(
backupDownloadProgress?.progress ?? 0
),
})}
</span>
);
}
@ -84,7 +112,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
if (lastBackup) {
return (
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<CheckCircleFillIcon />
<i style={{ color: vars.color.success }}>
<CheckCircleFillIcon />
</i>
{t("last_backup_date", {
date: format(lastBackup.createdAt, "dd/MM/yyyy HH:mm"),
})}
@ -97,7 +128,14 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
}
return t("no_backups");
}, [uploadingBackup, lastBackup, backupPreview, restoringBackup, t]);
}, [
uploadingBackup,
backupDownloadProgress?.progress,
lastBackup,
backupPreview,
restoringBackup,
t,
]);
const disableActions = uploadingBackup || restoringBackup || deletingArtifact;
@ -120,6 +158,22 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h2>{gameTitle}</h2>
<p>{backupStateLabel}</p>
<button
type="button"
style={{
margin: 0,
padding: 0,
alignSelf: "flex-start",
fontSize: 14,
cursor: "pointer",
textDecoration: "underline",
color: vars.color.body,
}}
onClick={() => setShowCloudSyncFilesModal(true)}
>
Gerenciar arquivos
</button>
</div>
<Button
@ -132,7 +186,17 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
</Button>
</div>
<h2 style={{ marginBottom: 16 }}>{t("backups")}</h2>
<div
style={{
marginBottom: 16,
display: "flex",
alignItems: "center",
gap: SPACING_UNIT,
}}
>
<h2>{t("backups")}</h2>
<small>2 / 2</small>
</div>
<ul className={styles.artifacts}>
{artifacts.map((artifact) => (
@ -163,10 +227,10 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button
type="button"
onClick={() => downloadGameArtifact(artifact.id)}
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
>
<DownloadIcon />
<HistoryIcon />
{t("install_backup")}
</Button>
<Button

View file

@ -14,6 +14,7 @@ import { steamUrlBuilder } from "@shared";
import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/cloud.json";
import { useUserDetails } from "@renderer/hooks";
const HERO_ANIMATION_THRESHOLD = 25;
@ -33,6 +34,8 @@ export function GameDetailsContent() {
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const { userDetails } = useUserDetails();
const { supportsCloudSync, setShowCloudSyncModal } =
useContext(cloudSyncContext);
@ -75,6 +78,15 @@ export function GameDetailsContent() {
setBackdropOpacity(opacity);
};
const handleCloudSaveButtonClick = () => {
if (!userDetails) {
window.electron.openAuthWindow();
return;
}
setShowCloudSyncModal(true);
};
return (
<div className={styles.wrapper({ blurredContent: hasNSFWContentBlocked })}>
<img
@ -113,7 +125,7 @@ export function GameDetailsContent() {
<button
type="button"
className={styles.cloudSyncButton}
onClick={() => setShowCloudSyncModal(true)}
onClick={handleCloudSaveButtonClick}
>
<div
style={{

View file

@ -27,6 +27,7 @@ import { useDownload } from "@renderer/hooks";
import { GameOptionsModal, RepacksModal } from "./modals";
import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
export function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@ -128,11 +129,23 @@ export function GameDetails() {
shop={shop! as GameShop}
>
<CloudSyncContextConsumer>
{({ showCloudSyncModal, setShowCloudSyncModal }) => (
<CloudSyncModal
onClose={() => setShowCloudSyncModal(false)}
visible={showCloudSyncModal}
/>
{({
showCloudSyncModal,
setShowCloudSyncModal,
showCloudSyncFilesModal,
setShowCloudSyncFilesModal,
}) => (
<>
<CloudSyncModal
onClose={() => setShowCloudSyncModal(false)}
visible={showCloudSyncModal}
/>
<CloudSyncFilesModal
onClose={() => setShowCloudSyncFilesModal(false)}
visible={showCloudSyncFilesModal}
/>
</>
)}
</CloudSyncContextConsumer>

View file

@ -1,6 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import parseTorrent from "parse-torrent";
import { Badge, Button, Modal, TextField } from "@renderer/components";
import type { GameRepack } from "@types";
@ -33,8 +32,6 @@ export function RepacksModal({
const [repack, setRepack] = useState<GameRepack | null>(null);
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
const [infoHash, setInfoHash] = useState<string | null>(null);
const { repacks, game } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@ -43,18 +40,9 @@ export function RepacksModal({
return orderBy(repacks, (repack) => repack.uploadDate, "desc");
}, [repacks]);
const getInfoHash = useCallback(async () => {
if (game?.uri?.startsWith("magnet:")) {
const torrent = await parseTorrent(game?.uri ?? "");
if (torrent.infoHash) setInfoHash(torrent.infoHash);
}
}, [game]);
useEffect(() => {
setFilteredRepacks(sortedRepacks);
if (game?.uri) getInfoHash();
}, [sortedRepacks, visible, game, getInfoHash]);
}, [sortedRepacks, visible, game]);
const handleRepackClick = (repack: GameRepack) => {
setRepack(repack);
@ -77,10 +65,8 @@ export function RepacksModal({
};
const checkIfLastDownloadedOption = (repack: GameRepack) => {
if (infoHash) return repack.uris.some((uri) => uri.includes(infoHash));
if (!game?.uri) return false;
return repack.uris.some((uri) => uri.includes(game?.uri ?? ""));
if (!game) return false;
return repack.uris.some((uri) => uri.includes(game.uri!));
};
return (

View file

@ -6,6 +6,7 @@ export const gameCover = style({
transition: "all ease 0.2s",
boxShadow: "0 8px 10px -2px rgba(0, 0, 0, 0.5)",
width: "100%",
position: "relative",
":before": {
content: "",
top: "0",

View file

@ -1,13 +1,13 @@
import { userProfileContext } from "@renderer/context";
import { useContext, useEffect, useMemo } from "react";
import { useCallback, useContext, useEffect, useMemo } from "react";
import { ProfileHero } from "../profile-hero/profile-hero";
import { useAppDispatch, useFormat } from "@renderer/hooks";
import { setHeaderTitle } from "@renderer/features";
import { steamUrlBuilder } from "@shared";
import { SPACING_UNIT } from "@renderer/theme.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import * as styles from "./profile-content.css";
import { TelescopeIcon } from "@primer/octicons-react";
import { ClockIcon, TelescopeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { LockedProfile } from "./locked-profile";
@ -16,6 +16,7 @@ import { FriendsBox } from "./friends-box";
import { RecentGamesBox } from "./recent-games-box";
import { UserGame } from "@types";
import { buildGameDetailsPath } from "@renderer/helpers";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
export function ProfileContent() {
const { userProfile, isMe, userStats } = useContext(userProfileContext);
@ -46,6 +47,22 @@ export function ProfileContent() {
objectID: game.objectId,
});
const formatPlayTime = useCallback(
(playTimeInSeconds = 0) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
},
[numberFormatter, t]
);
const content = useMemo(() => {
if (!userProfile) return null;
@ -109,13 +126,31 @@ export function ProfileContent() {
className={styles.gameCover}
onClick={() => navigate(buildUserGameDetailsPath(game))}
>
<div style={{ position: "absolute", padding: 4 }}>
<small
style={{
backgroundColor: vars.color.background,
color: vars.color.muted,
// border: `solid 1px ${vars.color.border}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 4px",
}}
>
<ClockIcon size={11} />
{formatPlayTime(game.playTimeInSeconds)}
</small>
</div>
<img
src={steamUrlBuilder.cover(game.objectId)}
alt={game.title}
style={{
width: "100%",
objectFit: "cover",
borderRadius: 4,
width: "100%",
height: "100%",
}}
/>
</button>

View file

@ -4,6 +4,7 @@ import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
flexDirection: "column",
position: "relative",
});
export const profileAvatarButton = style({

View file

@ -272,64 +272,86 @@ export function ProfileHero() {
<section
className={styles.profileContentBox}
style={{ background: heroBackground }}
// style={{ background: heroBackground }}
>
<div className={styles.userInformation}>
<button
type="button"
className={styles.profileAvatarButton}
onClick={handleAvatarClick}
>
{userProfile?.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile?.displayName}
src={userProfile?.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</button>
<img
src="https://wallpapers.com/images/featured/cyberpunk-anime-dfyw8eb7bqkw278u.jpg"
alt=""
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
<div
style={{
background: heroBackground,
width: "100%",
height: "100%",
zIndex: 1,
}}
>
<div className={styles.userInformation}>
<button
type="button"
className={styles.profileAvatarButton}
onClick={handleAvatarClick}
>
{userProfile?.profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={userProfile?.displayName}
src={userProfile?.profileImageUrl}
/>
) : (
<PersonIcon size={72} />
)}
</button>
<div className={styles.profileInformation}>
{userProfile ? (
<h2 className={styles.profileDisplayName}>
{userProfile?.displayName}
</h2>
) : (
<Skeleton width={150} height={28} />
)}
<div className={styles.profileInformation}>
{userProfile ? (
<h2 className={styles.profileDisplayName}>
{userProfile?.displayName}
</h2>
) : (
<Skeleton width={150} height={28} />
)}
{currentGame && (
<div className={styles.currentGameWrapper}>
<div className={styles.currentGameDetails}>
<Link
to={buildGameDetailsPath({
...currentGame,
objectID: currentGame.objectId,
})}
>
{currentGame.title}
</Link>
</div>
{currentGame && (
<div className={styles.currentGameWrapper}>
<div className={styles.currentGameDetails}>
<Link
to={buildGameDetailsPath({
...currentGame,
objectID: currentGame.objectId,
})}
>
{currentGame.title}
</Link>
</div>
<small>
{t("playing_for", {
amount: formatDistance(
addSeconds(
new Date(),
-currentGame.sessionDurationInSeconds
<small>
{t("playing_for", {
amount: formatDistance(
addSeconds(
new Date(),
-currentGame.sessionDurationInSeconds
),
new Date()
),
new Date()
),
})}
</small>
</div>
)}
})}
</small>
</div>
)}
</div>
</div>
</div>
<div className={styles.heroPanel}>
<div
className={styles.heroPanel}
style={{ background: heroBackground }}
>
<div
style={{
display: "flex",

View file

@ -67,8 +67,21 @@ export function AddDownloadSourceModal({
return;
}
const result = await window.electron.validateDownloadSource(values.url);
setValidationResult(result);
downloadSourcesWorker.postMessage([
"VALIDATE_DOWNLOAD_SOURCE",
values.url,
]);
const channel = new BroadcastChannel(
`download_sources:validate:${values.url}`
);
channel.onmessage = (
event: MessageEvent<DownloadSourceValidationResult>
) => {
setValidationResult(event.data);
channel.close();
};
setUrl(values.url);
},
@ -93,16 +106,14 @@ export function AddDownloadSourceModal({
if (validationResult) {
const channel = new BroadcastChannel(`download_sources:import:${url}`);
downloadSourcesWorker.postMessage([
"IMPORT_DOWNLOAD_SOURCE",
{ ...validationResult, url },
]);
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = () => {
setIsLoading(false);
onClose();
onAddDownloadSource();
channel.close();
};
}
};
@ -159,9 +170,9 @@ export function AddDownloadSourceModal({
<h4>{validationResult?.name}</h4>
<small>
{t("found_download_option", {
count: validationResult?.downloads.length,
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloads.length.toLocaleString(),
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>

View file

@ -59,6 +59,7 @@ export function SettingsDownloadSources() {
getDownloadSources();
indexRepacks();
setIsRemovingDownloadSource(false);
channel.close();
};
};
@ -71,15 +72,17 @@ export function SettingsDownloadSources() {
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
window.electron
.syncDownloadSources(downloadSources)
.then(() => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
})
.finally(() => {
setIsSyncingDownloadSources(false);
});
const id = crypto.randomUUID();
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
channel.onmessage = () => {
showSuccessToast(t("download_sources_synced"));
getDownloadSources();
setIsSyncingDownloadSources(false);
channel.close();
};
};
const statusTitle = {

View file

@ -1,16 +1,45 @@
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { z } from "zod";
import axios, { AxiosError, AxiosHeaders } from "axios";
import { DownloadSourceStatus } from "@shared";
import type { DownloadSourceValidationResult } from "@types";
export const downloadSourceSchema = z.object({
name: z.string().max(255),
downloads: z.array(
z.object({
title: z.string().max(255),
uris: z.array(z.string()),
uploadDate: z.string().max(255),
fileSize: z.string().max(255),
})
),
});
type Payload =
| ["IMPORT_DOWNLOAD_SOURCE", DownloadSourceValidationResult & { url: string }]
| ["DELETE_DOWNLOAD_SOURCE", number];
db.open();
| ["IMPORT_DOWNLOAD_SOURCE", string]
| ["DELETE_DOWNLOAD_SOURCE", number]
| ["VALIDATE_DOWNLOAD_SOURCE", string]
| ["SYNC_DOWNLOAD_SOURCES", string];
self.onmessage = async (event: MessageEvent<Payload>) => {
const [type, data] = event.data;
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
const { name } = downloadSourceSchema.parse(response.data);
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
channel.postMessage({
name,
etag: response.headers["etag"],
downloadCount: response.data.downloads.length,
});
}
if (type === "DELETE_DOWNLOAD_SOURCE") {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
await repacksTable.where({ downloadSourceId: data }).delete();
@ -23,28 +52,29 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
}
if (type === "IMPORT_DOWNLOAD_SOURCE") {
const result = data;
const response =
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
await db.transaction("rw", downloadSourcesTable, repacksTable, async () => {
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
const now = new Date();
const id = await downloadSourcesTable.add({
url: result.url,
name: result.name,
etag: result.etag,
url: data,
name: response.data.name,
etag: response.headers["etag"],
status: DownloadSourceStatus.UpToDate,
downloadCount: result.downloads.length,
downloadCount: response.data.downloads.length,
createdAt: now,
updatedAt: now,
});
const downloadSource = await downloadSourcesTable.get(id);
const repacks = result.downloads.map((download) => ({
const repacks = response.data.downloads.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: result.name,
repacker: response.data.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource!.id,
createdAt: now,
@ -54,10 +84,82 @@ self.onmessage = async (event: MessageEvent<Payload>) => {
await repacksTable.bulkAdd(repacks);
});
const channel = new BroadcastChannel(
`download_sources:import:${result.url}`
);
const channel = new BroadcastChannel(`download_sources:import:${data}`);
channel.postMessage(true);
}
if (type === "SYNC_DOWNLOAD_SOURCES") {
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
let newRepacksCount = 0;
try {
const downloadSources = await downloadSourcesTable.toArray();
const existingRepacks = await repacksTable.toArray();
for (const downloadSource of downloadSources) {
const headers = new AxiosHeaders();
if (downloadSource.etag) {
headers.set("If-None-Match", downloadSource.etag);
}
try {
const response = await axios.get(downloadSource.url, {
headers,
});
const source = downloadSourceSchema.parse(response.data);
await db.transaction(
"rw",
repacksTable,
downloadSourcesTable,
async () => {
await downloadSourcesTable.update(downloadSource.id, {
etag: response.headers["etag"],
downloadCount: source.downloads.length,
status: DownloadSourceStatus.UpToDate,
});
const now = new Date();
const repacks = source.downloads
.filter(
(download) =>
!existingRepacks.some(
(repack) => repack.title === download.title
)
)
.map((download) => ({
title: download.title,
uris: download.uris,
fileSize: download.fileSize,
repacker: source.name,
uploadDate: download.uploadDate,
downloadSourceId: downloadSource.id,
createdAt: now,
updatedAt: now,
}));
newRepacksCount += repacks.length;
await repacksTable.bulkAdd(repacks);
}
);
} catch (err: unknown) {
const isNotModified = (err as AxiosError).response?.status === 304;
await downloadSourcesTable.update(downloadSource.id, {
status: isNotModified
? DownloadSourceStatus.UpToDate
: DownloadSourceStatus.Errored,
});
}
}
channel.postMessage(newRepacksCount);
} catch (err) {
channel.postMessage(-1);
}
}
};

View file

@ -1,7 +1,5 @@
import MigrationWorker from "./migration.worker?worker";
import RepacksWorker from "./repacks.worker?worker";
import DownloadSourcesWorker from "./download-sources.worker?worker";
export const migrationWorker = new MigrationWorker();
export const repacksWorker = new RepacksWorker();
export const downloadSourcesWorker = new DownloadSourcesWorker();

View file

@ -1,35 +0,0 @@
// import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
import { DownloadSource, GameRepack } from "@types";
export type Payload = [DownloadSource[], GameRepack[]];
self.onmessage = async (_event: MessageEvent<Payload>) => {
// const [downloadSources, gameRepacks] = event.data;
// const downloadSourcesCount = await downloadSourcesTable.count();
// if (downloadSources.length > downloadSourcesCount) {
// await db.transaction(
// "rw",
// downloadSourcesTable,
// repacksTable,
// async () => {}
// );
// }
// if (type === "MIGRATE_DOWNLOAD_SOURCES") {
// const dexieDownloadSources = await downloadSourcesTable.count();
// if (data.length > dexieDownloadSources) {
// await downloadSourcesTable.clear();
// await downloadSourcesTable.bulkAdd(data);
// }
// self.postMessage("MIGRATE_DOWNLOAD_SOURCES_COMPLETE");
// }
// if (type === "MIGRATE_REPACKS") {
// const dexieRepacks = await repacksTable.count();
// if (data.length > dexieRepacks) {
// await repacksTable.clear();
// await repacksTable.bulkAdd(
// data.map((repack) => ({ ...repack, uris: JSON.stringify(repack.uris) }))
// );
// }
// self.postMessage("MIGRATE_REPACKS_COMPLETE");
// }
};