Merge branch 'main' into feat/disabling-update-badges

This commit is contained in:
Chubby Granny Chaser
2025-12-26 21:49:35 +00:00
committed by GitHub
49 changed files with 1845 additions and 351 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@@ -19,11 +19,14 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setExtractionProgress,
clearExtraction,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
import {
injectCustomCss,
@@ -78,6 +81,10 @@ export function App() {
const { showSuccessToast } = useToast();
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
useEffect(() => {
Promise.all([
levelDBService.get("userPreferences", null, "json"),
@@ -184,12 +191,23 @@ export function App() {
updateLibrary();
}),
window.electron.onSignOut(() => clearUserDetails()),
window.electron.onExtractionProgress((shop, objectId, progress) => {
dispatch(setExtractionProgress({ shop, objectId, progress }));
}),
window.electron.onExtractionComplete(() => {
dispatch(clearExtraction());
updateLibrary();
}),
window.electron.onArchiveDeletionPrompt((paths) => {
setArchivePaths(paths);
setShowArchiveDeletionModal(true);
}),
];
return () => {
listeners.forEach((unsubscribe) => unsubscribe());
};
}, [onSignIn, updateLibrary, clearUserDetails]);
}, [onSignIn, updateLibrary, clearUserDetails, dispatch]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
@@ -281,6 +299,12 @@ export function App() {
feature={hydraCloudFeature}
/>
<ArchiveDeletionModal
visible={showArchiveDeletionModal}
archivePaths={archivePaths}
onClose={() => setShowArchiveDeletionModal(false)}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
useDownload,
useLibrary,
useToast,
@@ -26,6 +27,8 @@ export function BottomPanel() {
const { lastPacket, progress, downloadSpeed, eta } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const [version, setVersion] = useState("");
const [sessionHash, setSessionHash] = useState<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
@@ -68,6 +71,20 @@ export function BottomPanel() {
return t("installing_common_redist", { log: commonRedistStatus });
}
if (extraction) {
const extractingGame = library.find(
(game) => game.id === extraction.visibleId
);
if (extractingGame) {
const extractionPercentage = Math.round(extraction.progress * 100);
return t("extracting", {
title: extractingGame.title,
percentage: `${extractionPercentage}%`,
});
}
}
const game = lastPacket
? library.find((game) => game.id === lastPacket?.gameId)
: undefined;
@@ -109,6 +126,7 @@ export function BottomPanel() {
eta,
downloadSpeed,
commonRedistStatus,
extraction,
]);
return (

View File

@@ -0,0 +1,82 @@
@use "../../scss/globals.scss";
.fullscreen-media-modal__overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(2px);
z-index: globals.$backdrop-z-index;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
.fullscreen-media-modal {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
position: relative;
margin: 0;
padding: 0;
border: none;
background: transparent;
max-width: none;
max-height: none;
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 4);
cursor: pointer;
color: globals.$body-color;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border: 1px solid globals.$border-color;
padding: globals.$spacing-unit;
display: flex;
align-items: center;
justify-content: center;
transition: all ease 0.2s;
z-index: 10;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
}
&__image-container {
max-width: 90%;
max-height: 90%;
display: flex;
justify-content: center;
align-items: center;
}
&__image {
max-width: 100%;
max-height: 60vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: image-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes image-appear {
0% {
opacity: 0;
transform: scale(0.85);
}
100% {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import "./fullscreen-media-modal.scss";
export interface FullscreenMediaModalProps {
visible: boolean;
onClose: () => void;
src: string | null | undefined;
alt?: string;
}
export function FullscreenMediaModal({
visible,
onClose,
src,
alt,
}: FullscreenMediaModalProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("modal");
useEffect(() => {
if (visible) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}
return () => {};
}, [onClose, visible]);
useEffect(() => {
const onMouseDown = (e: MouseEvent) => {
if (containerRef.current) {
const clickedOnImage = containerRef.current.contains(e.target as Node);
if (!clickedOnImage) {
onClose();
}
}
};
if (visible) {
window.addEventListener("mousedown", onMouseDown);
}
return () => {
window.removeEventListener("mousedown", onMouseDown);
};
}, [onClose, visible]);
if (!visible || !src) return null;
return createPortal(
<div className="fullscreen-media-modal__overlay">
<dialog className="fullscreen-media-modal" open aria-label={alt}>
<button
type="button"
onClick={onClose}
className="fullscreen-media-modal__close-button"
aria-label={t("close")}
>
<XIcon size={24} />
</button>
<div
ref={containerRef}
className="fullscreen-media-modal__image-container"
>
<img src={src} alt={alt} className="fullscreen-media-modal__image" />
</div>
</dialog>
</div>,
document.body
);
}

View File

@@ -20,3 +20,4 @@ export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";
export * from "./star-rating/star-rating";
export * from "./search-dropdown/search-dropdown";
export * from "./fullscreen-media-modal/fullscreen-media-modal";

View File

@@ -10,6 +10,8 @@ export const DOWNLOADER_NAME = {
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier",
[Downloader.FuckingFast]: "FuckingFast",
[Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus",
};

View File

@@ -208,6 +208,13 @@ declare global {
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer;
onExtractionProgress: (
cb: (shop: GameShop, objectId: string, progress: number) => void
) => () => Electron.IpcRenderer;
onArchiveDeletionPrompt: (
cb: (archivePaths: string[]) => void
) => () => Electron.IpcRenderer;
deleteArchive: (filePath: string) => Promise<boolean>;
getDefaultWinePrefixSelectionPath: () => Promise<string | null>;
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;

View File

@@ -1,17 +1,28 @@
import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import type { DownloadProgress } from "@types";
import type { DownloadProgress, GameShop } from "@types";
export interface ExtractionInfo {
visibleId: string;
progress: number;
}
export interface DownloadState {
lastPacket: DownloadProgress | null;
gameId: string | null;
gamesWithDeletionInProgress: string[];
extraction: ExtractionInfo | null;
peakSpeeds: Record<string, number>;
speedHistory: Record<string, number[]>;
}
const initialState: DownloadState = {
lastPacket: null,
gameId: null,
gamesWithDeletionInProgress: [],
extraction: null,
peakSpeeds: {},
speedHistory: {},
};
export const downloadSlice = createSlice({
@@ -21,6 +32,27 @@ export const downloadSlice = createSlice({
setLastPacket: (state, action: PayloadAction<DownloadProgress | null>) => {
state.lastPacket = action.payload;
if (!state.gameId && action.payload) state.gameId = action.payload.gameId;
// Track peak speed and speed history atomically when packet arrives
if (action.payload?.gameId && action.payload.downloadSpeed != null) {
const { gameId, downloadSpeed } = action.payload;
// Update peak speed if this is higher
const currentPeak = state.peakSpeeds[gameId] || 0;
if (downloadSpeed > currentPeak) {
state.peakSpeeds[gameId] = downloadSpeed;
}
// Update speed history for chart
if (!state.speedHistory[gameId]) {
state.speedHistory[gameId] = [];
}
state.speedHistory[gameId].push(downloadSpeed);
// Keep only last 120 entries
if (state.speedHistory[gameId].length > 120) {
state.speedHistory[gameId].shift();
}
}
},
clearDownload: (state) => {
state.lastPacket = null;
@@ -38,6 +70,37 @@ export const downloadSlice = createSlice({
const index = state.gamesWithDeletionInProgress.indexOf(action.payload);
if (index >= 0) state.gamesWithDeletionInProgress.splice(index, 1);
},
setExtractionProgress: (
state,
action: PayloadAction<{
shop: GameShop;
objectId: string;
progress: number;
}>
) => {
const { shop, objectId, progress } = action.payload;
state.extraction = {
visibleId: `${shop}:${objectId}`,
progress,
};
},
clearExtraction: (state) => {
state.extraction = null;
},
updatePeakSpeed: (
state,
action: PayloadAction<{ gameId: string; speed: number }>
) => {
const { gameId, speed } = action.payload;
const currentPeak = state.peakSpeeds[gameId] || 0;
if (speed > currentPeak) {
state.peakSpeeds[gameId] = speed;
}
},
clearPeakSpeed: (state, action: PayloadAction<string>) => {
state.peakSpeeds[action.payload] = 0;
state.speedHistory[action.payload] = [];
},
},
});
@@ -46,4 +109,8 @@ export const {
clearDownload,
setGameDeleting,
removeGameFromDeleting,
setExtractionProgress,
clearExtraction,
updatePeakSpeed,
clearPeakSpeed,
} = downloadSlice.actions;

View File

@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { ConfirmationModal } from "@renderer/components";
interface ArchiveDeletionModalProps {
visible: boolean;
archivePaths: string[];
onClose: () => void;
}
export function ArchiveDeletionModal({
visible,
archivePaths,
onClose,
}: Readonly<ArchiveDeletionModalProps>) {
const { t } = useTranslation("downloads");
const fullFileName =
archivePaths.length > 0 ? (archivePaths[0].split(/[/\\]/).pop() ?? "") : "";
const maxLength = 40;
const fileName =
fullFileName.length > maxLength
? `${fullFileName.slice(0, maxLength)}`
: fullFileName;
const handleConfirm = async () => {
for (const archivePath of archivePaths) {
await window.electron.deleteArchive(archivePath);
}
onClose();
};
return (
<ConfirmationModal
visible={visible}
title={t("delete_archive_title", { fileName })}
descriptionText={t("delete_archive_description")}
confirmButtonLabel={t("yes")}
cancelButtonLabel={t("no")}
onConfirm={handleConfirm}
onClose={onClose}
/>
);
}

View File

@@ -108,16 +108,11 @@
cursor: pointer;
display: flex;
align-items: center;
transition: opacity 0.2s ease;
transition: scale 0.2s ease;
outline: none;
&:hover {
opacity: 0.8;
}
&:focus,
&:focus-visible {
outline: none;
scale: 1.05;
}
}
@@ -395,6 +390,21 @@
flex-shrink: 0;
background-color: rgba(0, 0, 0, 0.3);
border: 1px solid globals.$border-color;
padding: 0;
cursor: pointer;
transition:
opacity 0.2s ease,
transform 0.2s ease;
&:hover {
opacity: 0.9;
}
&:focus,
&:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
img {
width: 100%;
@@ -411,6 +421,21 @@
gap: calc(globals.$spacing-unit / 1);
}
&__simple-title-button {
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
width: 100%;
transition: opacity 0.2s ease;
&:focus,
&:focus-visible {
outline: none;
}
}
&__simple-title {
font-size: 16px;
font-weight: 600;
@@ -511,5 +536,9 @@
background-color: #fff;
transition: width 0.3s ease;
border-radius: 4px;
&--extraction {
background-color: #fff;
}
}
}

View File

@@ -128,16 +128,20 @@ function SpeedChart({
g = 255,
b = 255;
if (color.startsWith("#")) {
const hex = color.replace("#", "");
r = Number.parseInt(hex.substring(0, 2), 16);
g = Number.parseInt(hex.substring(2, 4), 16);
b = Number.parseInt(hex.substring(4, 6), 16);
let hex = color.replace("#", "");
// Handle shorthand hex colors (e.g., "#fff" -> "#ffffff")
if (hex.length === 3) {
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
r = Number.parseInt(hex.substring(0, 2), 16) || 255;
g = Number.parseInt(hex.substring(2, 4), 16) || 255;
b = Number.parseInt(hex.substring(4, 6), 16) || 255;
} else if (color.startsWith("rgb")) {
const matches = color.match(/\d+/g);
if (matches && matches.length >= 3) {
r = Number.parseInt(matches[0]);
g = Number.parseInt(matches[1]);
b = Number.parseInt(matches[2]);
r = Number.parseInt(matches[0]) || 255;
g = Number.parseInt(matches[1]) || 255;
b = Number.parseInt(matches[2]) || 255;
}
}
const displaySpeeds = speeds.slice(-totalBars);
@@ -203,6 +207,7 @@ function SpeedChart({
interface HeroDownloadViewProps {
game: LibraryGame;
isGameDownloading: boolean;
isGameExtracting?: boolean;
downloadSpeed: number;
finalDownloadSize: string;
peakSpeed: number;
@@ -221,6 +226,7 @@ interface HeroDownloadViewProps {
function HeroDownloadView({
game,
isGameDownloading,
isGameExtracting = false,
downloadSpeed,
finalDownloadSize,
peakSpeed,
@@ -278,11 +284,17 @@ function HeroDownloadView({
<div className="download-group__progress-row download-group__progress-row--bar">
<div className="download-group__progress-wrapper">
<div className="download-group__progress-info-row">
{lastPacket?.isCheckingFiles ? (
{isGameExtracting && (
<span className="download-group__progress-status">
{t("extracting")}
</span>
)}
{!isGameExtracting && lastPacket?.isCheckingFiles && (
<span className="download-group__progress-status">
{t("checking_files")}
</span>
) : (
)}
{!isGameExtracting && !lastPacket?.isCheckingFiles && (
<span className="download-group__progress-size">
<DownloadIcon size={14} />
{isGameDownloading && lastPacket
@@ -293,7 +305,7 @@ function HeroDownloadView({
<span></span>
</div>
<div className="download-group__progress-info-row">
{!lastPacket?.isCheckingFiles && (
{!lastPacket?.isCheckingFiles && !isGameExtracting && (
<span className="download-group__progress-time">
{isGameDownloading &&
lastPacket?.timeRemaining &&
@@ -311,42 +323,44 @@ function HeroDownloadView({
</div>
<div className="download-group__progress-bar">
<div
className="download-group__progress-fill"
className={`download-group__progress-fill ${isGameExtracting ? "download-group__progress-fill--extraction" : ""}`}
style={{
width: `${currentProgress * 100}%`,
}}
/>
</div>
</div>
<div className="download-group__hero-buttons">
{isGameDownloading ? (
{!isGameExtracting && (
<div className="download-group__hero-buttons">
{isGameDownloading ? (
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => pauseDownload(game.shop, game.objectId)}
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<ColumnsIcon size={14} />
{t("pause")}
<XCircleIcon size={14} />
{t("cancel")}
</button>
) : (
<button
type="button"
onClick={() => resumeDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<PlayIcon size={14} />
{t("resume")}
</button>
)}
<button
type="button"
onClick={() => cancelDownload(game.shop, game.objectId)}
className="download-group__glass-btn"
>
<XCircleIcon size={14} />
{t("cancel")}
</button>
</div>
</div>
)}
</div>
</div>
@@ -398,10 +412,12 @@ function HeroDownloadView({
</div>
)}
{game.download?.downloader && (
{game.download?.downloader !== undefined && (
<div className="download-group__stat-item">
<div className="download-group__stat-content">
<Badge>{DOWNLOADER_NAME[game.download.downloader]}</Badge>
<Badge>
{DOWNLOADER_NAME[Number(game.download.downloader)]}
</Badge>
</div>
</div>
)}
@@ -436,11 +452,14 @@ export function DownloadGroup({
seedingStatus,
}: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads");
const navigate = useNavigate();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const extraction = useAppSelector((state) => state.download.extraction);
const { updateLibrary } = useLibrary();
const {
@@ -495,8 +514,9 @@ export function DownloadGroup({
const { formatDistance } = useDate();
const [peakSpeeds, setPeakSpeeds] = useState<Record<string, number>>({});
const speedHistoryRef = useRef<Record<string, number[]>>({});
// Get speed history and peak speeds from Redux (centralized state)
const speedHistory = useAppSelector((state) => state.download.speedHistory);
const peakSpeeds = useAppSelector((state) => state.download.peakSpeeds);
const [dominantColors, setDominantColors] = useState<Record<string, string>>(
{}
);
@@ -559,68 +579,8 @@ export function DownloadGroup({
});
}, [library, lastPacket?.gameId]);
useEffect(() => {
if (lastPacket?.gameId && lastPacket.downloadSpeed !== undefined) {
const gameId = lastPacket.gameId;
const currentPeak = peakSpeeds[gameId] || 0;
if (lastPacket.downloadSpeed > currentPeak) {
setPeakSpeeds((prev) => ({
...prev,
[gameId]: lastPacket.downloadSpeed,
}));
}
if (!speedHistoryRef.current[gameId]) {
speedHistoryRef.current[gameId] = [];
}
speedHistoryRef.current[gameId].push(lastPacket.downloadSpeed);
if (speedHistoryRef.current[gameId].length > 120) {
speedHistoryRef.current[gameId].shift();
}
}
}, [lastPacket?.gameId, lastPacket?.downloadSpeed, peakSpeeds]);
useEffect(() => {
for (const game of library) {
if (
game.download &&
game.download.progress < 0.01 &&
game.download.status !== "paused"
) {
// Fresh download - clear any old data
if (speedHistoryRef.current[game.id]?.length > 0) {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}
}
}
}, [library]);
useEffect(() => {
const timeouts: NodeJS.Timeout[] = [];
for (const game of library) {
if (
game.download?.progress === 1 &&
speedHistoryRef.current[game.id]?.length > 0
) {
const timeout = setTimeout(() => {
speedHistoryRef.current[game.id] = [];
setPeakSpeeds((prev) => ({ ...prev, [game.id]: 0 }));
}, 10_000);
timeouts.push(timeout);
}
}
return () => {
for (const timeout of timeouts) {
clearTimeout(timeout);
}
};
}, [library]);
// Speed history and peak speeds are now tracked in Redux (in setLastPacket reducer)
// No local effect needed - data is updated atomically when packets arrive
useEffect(() => {
if (library.length > 0 && title === t("download_in_progress")) {
@@ -818,16 +778,28 @@ export function DownloadGroup({
if (isDownloadingGroup && library.length > 0) {
const game = library[0];
const isGameDownloading = isGameDownloadingMap[game.id];
const isGameExtracting = extraction?.visibleId === game.id;
const isGameDownloading =
isGameDownloadingMap[game.id] && !isGameExtracting;
const downloadSpeed = isGameDownloading
? (lastPacket?.downloadSpeed ?? 0)
: 0;
const finalDownloadSize = getFinalDownloadSize(game);
const peakSpeed = peakSpeeds[game.id] || 0;
const currentProgress =
isGameDownloading && lastPacket
? lastPacket.progress
: game.download?.progress || 0;
// Use lastPacket.gameId for lookup since that's the key used to store the data
// Fall back to game.id if lastPacket is not available
const dataKey = lastPacket?.gameId ?? game.id;
const gameSpeedHistory = speedHistory[dataKey] ?? [];
const storedPeak = peakSpeeds[dataKey];
// Use stored peak if available and > 0, otherwise use current speed as initial value
const peakSpeed =
storedPeak !== undefined && storedPeak > 0 ? storedPeak : downloadSpeed;
let currentProgress = game.download?.progress || 0;
if (isGameExtracting) {
currentProgress = extraction.progress;
} else if (isGameDownloading && lastPacket) {
currentProgress = lastPacket.progress;
}
const dominantColor = dominantColors[game.id] || "#fff";
@@ -835,13 +807,14 @@ export function DownloadGroup({
<HeroDownloadView
game={game}
isGameDownloading={isGameDownloading}
isGameExtracting={isGameExtracting}
downloadSpeed={downloadSpeed}
finalDownloadSize={finalDownloadSize}
peakSpeed={peakSpeed}
currentProgress={currentProgress}
dominantColor={dominantColor}
lastPacket={lastPacket}
speedHistory={speedHistoryRef.current[game.id] || []}
speedHistory={gameSpeedHistory}
formatSpeed={formatSpeed}
calculateETA={calculateETA}
pauseDownload={pauseDownload}
@@ -867,20 +840,33 @@ export function DownloadGroup({
{downloadInfo.map(({ game, size, progress, isSeeding: seeding }) => {
return (
<li key={game.id} className="download-group__simple-card">
<div className="download-group__simple-thumbnail">
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-thumbnail"
>
<img src={game.libraryImageUrl || ""} alt={game.title} />
</div>
</button>
<div className="download-group__simple-info">
<h3 className="download-group__simple-title">{game.title}</h3>
<button
type="button"
onClick={() => navigate(buildGameDetailsPath(game))}
className="download-group__simple-title-button"
>
<h3 className="download-group__simple-title">{game.title}</h3>
</button>
<div className="download-group__simple-meta">
<div className="download-group__simple-meta-row">
<Badge>{DOWNLOADER_NAME[game.download!.downloader]}</Badge>
<Badge>
{DOWNLOADER_NAME[Number(game.download!.downloader)]}
</Badge>
</div>
<div className="download-group__simple-meta-row">
{game.download?.extracting ? (
{extraction?.visibleId === game.id ? (
<span className="download-group__simple-extracting">
{t("extracting")}
{t("extracting")} (
{Math.round(extraction.progress * 100)}%)
</span>
) : (
<span className="download-group__simple-size">

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
import { BinaryNotFoundModal } from "../shared-modals/binary-not-found-modal";
@@ -13,6 +13,7 @@ import { ArrowDownIcon } from "@primer/octicons-react";
export default function Downloads() {
const { library, updateLibrary } = useLibrary();
const extraction = useAppSelector((state) => state.download.extraction);
const { t } = useTranslation("downloads");
@@ -39,11 +40,13 @@ export default function Downloads() {
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
const unsubscribe = window.electron.onExtractionComplete(() => {
const unsubscribeExtraction = window.electron.onExtractionComplete(() => {
updateLibrary();
});
return () => unsubscribe();
return () => {
unsubscribeExtraction();
};
}, [updateLibrary]);
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
@@ -72,8 +75,10 @@ export default function Downloads() {
/* Game has been manually added to the library */
if (!next.download) return prev;
/* Is downloading */
if (lastPacket?.gameId === next.id || next.download.extracting)
/* Is downloading or extracting */
const isExtracting =
next.download.extracting || extraction?.visibleId === next.id;
if (lastPacket?.gameId === next.id || isExtracting)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */
@@ -96,7 +101,7 @@ export default function Downloads() {
queued,
complete,
};
}, [library, lastPacket?.gameId]);
}, [library, lastPacket?.gameId, extraction?.visibleId]);
const downloadGroups = [
{

View File

@@ -1,7 +1,12 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatDownloadProgress } from "@renderer/helpers";
import { useDate, useDownload, useFormat } from "@renderer/hooks";
import {
useAppSelector,
useDate,
useDownload,
useFormat,
} from "@renderer/hooks";
import { Link } from "@renderer/components";
import { gameDetailsContext } from "@renderer/context";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
@@ -17,6 +22,9 @@ export function HeroPanelPlaytime() {
const { numberFormatter } = useFormat();
const { progress, lastPacket } = useDownload();
const { formatDistance } = useDate();
const extraction = useAppSelector((state) => state.download.extraction);
const isExtracting = extraction?.visibleId === game?.id;
useEffect(() => {
if (game?.lastTimePlayed) {
@@ -52,6 +60,16 @@ export function HeroPanelPlaytime() {
const isGameDownloading =
game.download?.status === "active" && lastPacket?.gameId === game.id;
const extractionInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
{t("extracting")}
</Link>
<small>{formatDownloadProgress(extraction?.progress ?? 0)}</small>
</div>
);
const downloadInProgressInfo = (
<div className="hero-panel-playtime__download-details">
<Link to="/downloads" className="hero-panel-playtime__downloads-link">
@@ -72,7 +90,8 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("not_played_yet", { title: game?.title })}</p>
{hasDownload && downloadInProgressInfo}
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
</>
);
}
@@ -81,7 +100,8 @@ export function HeroPanelPlaytime() {
return (
<>
<p>{t("playing_now")}</p>
{hasDownload && downloadInProgressInfo}
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
</>
);
}
@@ -113,9 +133,9 @@ export function HeroPanelPlaytime() {
})}
</p>
{hasDownload ? (
downloadInProgressInfo
) : (
{isExtracting && extractionInProgressInfo}
{!isExtracting && hasDownload && downloadInProgressInfo}
{!isExtracting && !hasDownload && (
<p>
{t("last_time_played", {
period: lastTimePlayed,

View File

@@ -80,5 +80,11 @@
&--disabled {
opacity: globals.$disabled-opacity;
}
&--extraction {
&::-webkit-progress-value {
background-color: #fff;
}
}
}
}

View File

@@ -1,7 +1,7 @@
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { useDate, useDownload } from "@renderer/hooks";
import { useAppSelector, useDate, useDownload } from "@renderer/hooks";
import { HeroPanelActions } from "./hero-panel-actions";
import { HeroPanelPlaytime } from "./hero-panel-playtime";
@@ -18,9 +18,13 @@ export function HeroPanel() {
const { lastPacket } = useDownload();
const extraction = useAppSelector((state) => state.download.extraction);
const isGameDownloading =
game?.download?.status === "active" && lastPacket?.gameId === game?.id;
const isExtracting = extraction?.visibleId === game?.id;
const getInfo = () => {
if (!game) {
const [latestRepack] = repacks;
@@ -49,6 +53,8 @@ export function HeroPanel() {
(game?.download?.status === "active" && game?.download?.progress < 1) ||
game?.download?.status === "paused";
const showExtractionProgressBar = isExtracting;
return (
<div className="hero-panel__container">
<div className="hero-panel">
@@ -72,6 +78,14 @@ export function HeroPanel() {
}`}
/>
)}
{showExtractionProgressBar && (
<progress
max={1}
value={extraction?.progress ?? 0}
className="hero-panel__progress-bar hero-panel__progress-bar--extraction"
/>
)}
</div>
</div>
);

View File

@@ -21,7 +21,7 @@ import { UserKarmaBox } from "./user-karma-box";
import { DeleteReviewModal } from "@renderer/pages/game-details/modals/delete-review-modal";
import { GAME_STATS_ANIMATION_DURATION_IN_MS } from "./profile-animations";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { ProfileTabs } from "./profile-tabs";
import { ProfileTabs, type ProfileTabType } from "./profile-tabs";
import { LibraryTab } from "./library-tab";
import { ReviewsTab } from "./reviews-tab";
import { AnimatePresence } from "framer-motion";
@@ -95,7 +95,7 @@ export function ProfileContent() {
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
const statsAnimation = useRef(-1);
const [activeTab, setActiveTab] = useState<"library" | "reviews">("library");
const [activeTab, setActiveTab] = useState<ProfileTabType>("library");
// User reviews state
const [reviews, setReviews] = useState<UserReview[]>([]);

View File

@@ -2,10 +2,12 @@ import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import "./profile-content.scss";
export type ProfileTabType = "library" | "reviews";
interface ProfileTabsProps {
activeTab: "library" | "reviews";
activeTab: ProfileTabType;
reviewsTotalCount: number;
onTabChange: (tab: "library" | "reviews") => void;
onTabChange: (tab: ProfileTabType) => void;
}
export function ProfileTabs({

View File

@@ -0,0 +1,100 @@
@use "../../../scss/globals.scss";
.wrapped-fullscreen-modal {
position: fixed;
inset: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: none;
background: transparent;
width: 100%;
height: 100%;
&__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.9);
border: none;
z-index: 1;
}
&__container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: calc(globals.$spacing-unit * 2);
pointer-events: none;
z-index: 2;
}
&__close-button {
position: absolute;
top: calc(globals.$spacing-unit * 5);
right: calc(globals.$spacing-unit * 5);
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: background 0.2s ease;
z-index: 10;
pointer-events: auto;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
&__content {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
pointer-events: auto;
background: rgba(0, 0, 0, 0.5);
}
&__loader {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
&__spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: white;
border-radius: 50%;
animation: wrapped-spin 0.8s linear infinite;
}
&__iframe {
width: 100%;
height: 100%;
border: none;
}
}
@keyframes wrapped-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { XIcon } from "@primer/octicons-react";
import "./wrapped-tab.scss";
interface WrappedFullscreenModalProps {
userId: string;
isOpen: boolean;
onClose: () => void;
}
interface ScaleConfig {
scale: number;
width: number;
height: number;
}
const SCALE_CONFIGS: Record<number, ScaleConfig> = {
0.25: { scale: 0.25, width: 270, height: 480 },
0.3: { scale: 0.3, width: 324, height: 576 },
0.5: { scale: 0.5, width: 540, height: 960 },
};
const getScaleConfigForHeight = (height: number): ScaleConfig => {
if (height >= 1000) return SCALE_CONFIGS[0.5];
if (height >= 650) return SCALE_CONFIGS[0.3];
return SCALE_CONFIGS[0.25];
};
export function WrappedFullscreenModal({
userId,
isOpen,
onClose,
}: Readonly<WrappedFullscreenModalProps>) {
const [config, setConfig] = useState<ScaleConfig>(SCALE_CONFIGS[0.5]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!isOpen) return;
const updateConfig = () => {
setConfig(getScaleConfigForHeight(window.innerHeight));
};
updateConfig();
window.addEventListener("resize", updateConfig);
return () => window.removeEventListener("resize", updateConfig);
}, [isOpen]);
useEffect(() => {
if (isOpen) {
setIsLoading(true);
}
}, [isOpen]);
if (!isOpen) return null;
return (
<dialog className="wrapped-fullscreen-modal" aria-modal="true" open>
<button
type="button"
className="wrapped-fullscreen-modal__backdrop"
onClick={onClose}
aria-label="Close wrapped"
/>
<div className="wrapped-fullscreen-modal__container">
<button
type="button"
className="wrapped-fullscreen-modal__close-button"
onClick={onClose}
aria-label="Close wrapped"
>
<XIcon size={24} />
</button>
<div
className="wrapped-fullscreen-modal__content"
style={{ width: config.width, height: config.height }}
>
{isLoading && (
<div className="wrapped-fullscreen-modal__loader">
<div className="wrapped-fullscreen-modal__spinner" />
</div>
)}
<iframe
src={`https://hydrawrapped.com/embed/${userId}?scale=${config.scale}`}
className="wrapped-fullscreen-modal__iframe"
title="Wrapped 2025"
onLoad={() => setIsLoading(false)}
/>
</div>
</div>
</dialog>
);
}

View File

@@ -120,6 +120,11 @@
}
}
&__left-actions {
display: flex;
gap: globals.$spacing-unit;
}
&__actions {
display: flex;
gap: globals.$spacing-unit;
@@ -131,5 +136,35 @@
&--outline {
border-color: globals.$body-color;
}
&--wrapped {
background: linear-gradient(
120deg,
#2a57ff 0%,
#2951e6 11%,
#2f5bff 16%,
#2c56e8 29%,
#244acc 34%,
#2245c2 40%,
#3a6bff 45%,
#3766f2 50%,
#2444b8 56%,
#122a73 82%,
#2348b3 86%,
#1f429e 87%,
#10286a 93%,
#0e2a63 100%
);
background-color: #2a57ff;
background-size: 105% 100%;
background-position: 100% 50%;
border: none;
color: white;
transition: background-position 0.4s ease;
&:hover {
background-position: 0% 50%;
}
}
}
}

View File

@@ -6,10 +6,16 @@ import {
PencilIcon,
PersonAddIcon,
SignOutIcon,
TrophyIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { Avatar, Button, Link } from "@renderer/components";
import {
Avatar,
Button,
FullscreenMediaModal,
Link,
} from "@renderer/components";
import { useTranslation } from "react-i18next";
import {
useAppSelector,
@@ -22,6 +28,7 @@ import { useNavigate } from "react-router-dom";
import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import { Tooltip } from "react-tooltip";
@@ -33,6 +40,8 @@ type FriendAction =
export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showWrappedModal, setShowWrappedModal] = useState(false);
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false);
const {
@@ -246,10 +255,12 @@ export function ProfileHero() {
]);
const handleAvatarClick = useCallback(() => {
if (isMe) {
if (userProfile?.profileImageUrl) {
setShowFullscreenAvatar(true);
} else if (isMe) {
setShowEditProfileModal(true);
}
}, [isMe]);
}, [isMe, userProfile?.profileImageUrl]);
const currentGame = useMemo(() => {
if (isMe) {
@@ -272,6 +283,20 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)}
/>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
<FullscreenMediaModal
visible={showFullscreenAvatar}
onClose={() => setShowFullscreenAvatar(false)}
src={userProfile?.profileImageUrl}
alt={userProfile?.displayName}
/>
<section
className="profile-hero__content-box"
style={{ background: !backgroundImage ? heroBackground : undefined }}
@@ -378,6 +403,22 @@ export function ProfileHero() {
background: !backgroundImage ? heroBackground : undefined,
}}
>
{userProfile?.hasCompletedWrapped2025 && (
<div className="profile-hero__left-actions">
<Button
theme="outline"
onClick={() => setShowWrappedModal(true)}
className="profile-hero__button--wrapped"
>
<TrophyIcon />
{isMe
? t("view_my_wrapped_button")
: t("view_wrapped_button", {
displayName: userProfile.displayName,
})}
</Button>
</div>
)}
<div className="profile-hero__actions">{profileActions}</div>
</div>
</section>