Compare commits

..

20 Commits

Author SHA1 Message Date
Chubby Granny Chaser
35736dd2d9 Merge branch 'main' into feat/wrapped-in-profile 2025-12-26 21:39:24 +00:00
Chubby Granny Chaser
86d5547aa1 Merge pull request #1903 from Wkeynhk/main
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
Adding new hosters
2025-12-25 23:13:24 +00:00
Wkeynhk
358f41b4ba . 2025-12-26 01:34:09 +03:00
Wkeynhk
7f0dc5eee4 . 2025-12-26 01:25:56 +03:00
Wkeynhk
067f7a00be . 2025-12-26 01:19:04 +03:00
Wkeynhk
37f085e2c0 . 2025-12-26 01:13:36 +03:00
Wkeynhk
f8ac284bc2 New hosters 2025-12-25 14:16:11 +03:00
Chubby Granny Chaser
b1d72828bb Merge branch 'main' into feat/wrapped-in-profile 2025-12-19 02:03:03 +00:00
Chubby Granny Chaser
4c09f915c6 Merge pull request #1901 from hydralauncher/fix/fullscreen-modal
Some checks failed
Build / build (ubuntu-latest) (push) Has been cancelled
Build / build (windows-2022) (push) Has been cancelled
style: enhance overlay styling and adjust close button positioning
2025-12-19 02:02:46 +00:00
Moyase
f77b2116c1 Merge branch 'main' into feat/wrapped-in-profile 2025-12-18 22:44:25 +02:00
Moyasee
ccb754fa13 feat(profile): add hasCompletedWrapped2025 flag to UserProfile and update ProfileHero rendering logic 2025-12-14 15:14:54 +02:00
Moyasee
5329cc446f Merge branch 'feat/wrapped-in-profile' of https://github.com/hydralauncher/hydra into feat/wrapped-in-profile 2025-12-13 09:17:02 +02:00
Moyasee
21a0ad1500 feat(profile): add translation for "View My Wrapped 2025" button 2025-12-13 09:16:54 +02:00
Moyase
9ffaee12d1 Merge branch 'main' into feat/wrapped-in-profile 2025-12-12 20:44:15 +02:00
Moyasee
8555274589 ci: formatting 2025-12-12 20:43:14 +02:00
Moyasee
a152c89d7f feat(profile): update Wrapped 2025 button and add new gradient style 2025-12-12 20:42:54 +02:00
Moyasee
879f0baad7 refactor(profile): remove overlay from WrappedFullscreenModal to streamline UI 2025-12-12 17:51:31 +02:00
Moyasee
c025dc199d style(profile): add overlay to WrappedFullscreenModal for improved UI interaction 2025-12-12 17:49:12 +02:00
Moyasee
5b4b258526 refactor: remove WrappedConfirmModal and integrate WrappedFullscreenModal in profile 2025-12-12 17:29:44 +02:00
Moyasee
0268829946 feat: add Wrapped 2025 view in profile 2025-12-12 13:53:12 +02:00
17 changed files with 627 additions and 92 deletions

View File

@@ -723,7 +723,10 @@
"karma_description": "Earned from positive likes on reviews",
"user_reviews": "Reviews",
"delete_review": "Delete Review",
"loading_reviews": "Loading reviews..."
"loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
},
"library": {
"library": "Library",

View File

@@ -41,7 +41,6 @@ const startGameDownload = async (
const game = await gamesSublevel.get(gameKey);
const gameAssets = await gamesShopAssetsSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey);
if (game) {
@@ -124,6 +123,42 @@ const startGameDownload = async (
}
if (err instanceof Error) {
if (downloader === Downloader.Buzzheavier) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "Buzzheavier: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "Buzzheavier: File not found",
};
}
}
if (downloader === Downloader.FuckingFast) {
if (err.message.includes("Rate limit")) {
return {
ok: false,
error: "FuckingFast: Rate limit exceeded",
};
}
if (
err.message.includes("not found") ||
err.message.includes("deleted")
) {
return {
ok: false,
error: "FuckingFast: File not found",
};
}
}
return { ok: false, error: err.message };
}

View File

@@ -20,14 +20,59 @@ import { RealDebridClient } from "./real-debrid";
import path from "path";
import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { orderBy } from "lodash-es";
import { sortBy } from "lodash-es";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { BuzzheavierApi, FuckingFastApi } from "@main/services/hosters";
export class DownloadManager {
private static downloadingGameId: string | null = null;
private static extractFilename(url: string, originalUrl?: string): string | undefined {
if (originalUrl?.includes('#')) {
const hashPart = originalUrl.split('#')[1];
if (hashPart && !hashPart.startsWith('http')) return hashPart;
}
if (url.includes('#')) {
const hashPart = url.split('#')[1];
if (hashPart && !hashPart.startsWith('http')) return hashPart;
}
try {
const urlObj = new URL(url);
const filename = urlObj.pathname.split('/').pop();
if (filename?.length) return filename;
} catch {
// Invalid URL
}
return undefined;
}
private static sanitizeFilename(filename: string): string {
return filename.replace(/[<>:"/\\|?*]/g, '_');
}
private static createDownloadPayload(directUrl: string, originalUrl: string, downloadId: string, savePath: string) {
const filename = this.extractFilename(directUrl, originalUrl);
const sanitizedFilename = filename ? this.sanitizeFilename(filename) : undefined;
if (sanitizedFilename) {
logger.log(`[DownloadManager] Using filename: ${sanitizedFilename}`);
}
return {
action: "start" as const,
game_id: downloadId,
url: directUrl,
save_path: savePath,
out: sanitizedFilename,
allow_multiple_connections: true,
};
}
public static async startRPC(
download?: Download,
downloadsToSeed?: Download[]
@@ -53,9 +98,7 @@ export class DownloadManager {
}
private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status"
);
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>("/status");
if (response.data === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
@@ -71,8 +114,7 @@ export class DownloadManager {
status,
} = response.data;
const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata;
const isDownloadingMetadata = status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
const download = await downloadsSublevel.get(downloadId);
@@ -121,29 +163,29 @@ export class DownloadManager {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
{ valueEncoding: "json" }
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...status, game }))
);
}
const shouldExtract = download.automaticallyExtract;
// Handle download completion BEFORE sending progress to renderer
// This ensures extraction starts and DB is updated before UI reacts
if (progress === 1 && download) {
publishDownloadCompleteNotification(game);
if (
userPreferences?.seedAfterDownloadComplete &&
download.downloader === Downloader.Torrent
) {
if (userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent) {
await downloadsSublevel.put(gameId, {
...download,
status: "seeding",
shouldSeed: true,
queued: false,
extracting: shouldExtract,
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
});
} else {
await downloadsSublevel.put(gameId, {
@@ -152,54 +194,31 @@ export class DownloadManager {
shouldSeed: false,
queued: false,
extracting: shouldExtract,
extractionProgress: shouldExtract ? 0 : download.extractionProgress,
});
this.cancelDownload(gameId);
}
if (shouldExtract) {
// Send initial extraction progress BEFORE download progress
// This ensures the UI shows extraction immediately
WindowManager.mainWindow?.webContents.send(
"on-extraction-progress",
game.shop,
game.objectId,
0
);
const gameFilesManager = new GameFilesManager(game.shop, game.objectId);
const gameFilesManager = new GameFilesManager(
game.shop,
game.objectId
);
if (
FILE_EXTENSIONS_TO_EXTRACT.some((ext) =>
download.folderName?.endsWith(ext)
)
) {
if (FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext))) {
gameFilesManager.extractDownloadedFile();
} else {
gameFilesManager
.extractFilesInDirectory(
path.join(download.downloadPath, download.folderName!)
)
.then(() => {
gameFilesManager.setExtractionComplete();
});
.extractFilesInDirectory(path.join(download.downloadPath, download.folderName!))
.then(() => gameFilesManager.setExtractionComplete());
}
}
const downloads = await downloadsSublevel
.values()
.all()
.then((games) => {
return orderBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"desc"
);
});
.then((games) => sortBy(
games.filter((game) => game.status === "paused" && game.queued),
"timestamp",
"DESC"
));
const [nextItemOnQueue] = downloads;
@@ -209,18 +228,6 @@ export class DownloadManager {
this.downloadingGameId = null;
}
}
// Send progress to renderer after completion handling
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
structuredClone({
...status,
game,
})
);
}
}
}
@@ -238,9 +245,7 @@ export class DownloadManager {
if (!download) return;
const totalSize = await getDirSize(
path.join(download.downloadPath, status.folderName)
);
const totalSize = await getDirSize(path.join(download.downloadPath, status.folderName));
if (totalSize < status.fileSize) {
await this.cancelDownload(status.gameId);
@@ -261,10 +266,7 @@ export class DownloadManager {
static async pauseDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", {
action: "pause",
game_id: downloadKey,
} as PauseDownloadPayload)
.post("/action", { action: "pause", game_id: downloadKey } as PauseDownloadPayload)
.catch(() => {});
if (downloadKey === this.downloadingGameId) {
@@ -279,13 +281,8 @@ export class DownloadManager {
static async cancelDownload(downloadKey = this.downloadingGameId) {
await PythonRPC.rpc
.post("/action", {
action: "cancel",
game_id: downloadKey,
})
.catch((err) => {
logger.error("Failed to cancel game download", err);
});
.post("/action", { action: "cancel", game_id: downloadKey })
.catch((err) => logger.error("Failed to cancel game download", err));
if (downloadKey === this.downloadingGameId) {
WindowManager.mainWindow?.setProgressBar(-1);
@@ -318,7 +315,6 @@ export class DownloadManager {
const id = download.uri.split("/").pop();
const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!);
await GofileApi.checkDownloadUrl(downloadLink);
return {
@@ -360,9 +356,30 @@ export class DownloadManager {
save_path: download.downloadPath,
};
}
case Downloader.Buzzheavier: {
logger.log(`[DownloadManager] Processing Buzzheavier download for URI: ${download.uri}`);
try {
const directUrl = await BuzzheavierApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] Buzzheavier direct URL obtained`);
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
} catch (error) {
logger.error(`[DownloadManager] Error processing Buzzheavier download:`, error);
throw error;
}
}
case Downloader.FuckingFast: {
logger.log(`[DownloadManager] Processing FuckingFast download for URI: ${download.uri}`);
try {
const directUrl = await FuckingFastApi.getDirectLink(download.uri);
logger.log(`[DownloadManager] FuckingFast direct URL obtained`);
return this.createDownloadPayload(directUrl, download.uri, downloadId, download.downloadPath);
} catch (error) {
logger.error(`[DownloadManager] Error processing FuckingFast download:`, error);
throw error;
}
}
case Downloader.Mediafire: {
const downloadUrl = await MediafireApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
@@ -379,7 +396,6 @@ export class DownloadManager {
};
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnRealDebrid);
return {
@@ -392,7 +408,6 @@ export class DownloadManager {
}
case Downloader.TorBox: {
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
if (!url) return;
return {
action: "start",
@@ -404,10 +419,7 @@ export class DownloadManager {
};
}
case Downloader.Hydra: {
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {

View File

@@ -0,0 +1,82 @@
import axios from "axios";
import {
HOSTER_USER_AGENT,
extractHosterFilename,
handleHosterError,
} from "./fuckingfast";
import { logger } from "@main/services";
export class BuzzheavierApi {
private static readonly BUZZHEAVIER_DOMAINS = [
"buzzheavier.com",
"bzzhr.co",
"fuckingfast.net",
];
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.BUZZHEAVIER_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getBuzzheavierDirectLink(url: string): Promise<string> {
try {
const baseUrl = url.split("#")[0];
logger.log(`[Buzzheavier] Starting download link extraction for: ${baseUrl}`);
await axios.get(baseUrl, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const downloadUrl = `${baseUrl}/download`;
logger.log(`[Buzzheavier] Making HEAD request to: ${downloadUrl}`);
const headResponse = await axios.head(downloadUrl, {
headers: {
"hx-current-url": baseUrl,
"hx-request": "true",
referer: baseUrl,
"User-Agent": HOSTER_USER_AGENT,
},
maxRedirects: 0,
validateStatus: (status) =>
status === 200 || status === 204 || status === 301 || status === 302,
timeout: 30000,
});
const hxRedirect = headResponse.headers["hx-redirect"];
logger.log(`[Buzzheavier] Received hx-redirect header: ${hxRedirect}`);
if (!hxRedirect) {
logger.error(`[Buzzheavier] No hx-redirect header found. Status: ${headResponse.status}`);
throw new Error(
"Could not extract download link. File may be deleted or is a directory."
);
}
const domain = new URL(baseUrl).hostname;
const directLink = hxRedirect.startsWith("/dl/")
? `https://${domain}${hxRedirect}`
: hxRedirect;
logger.log(`[Buzzheavier] Extracted direct link`);
return directLink;
} catch (error) {
logger.error(`[Buzzheavier] Error in getBuzzheavierDirectLink:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.BUZZHEAVIER_DOMAINS.join(", ")}`
);
}
return this.getBuzzheavierDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -0,0 +1,129 @@
import axios from "axios";
import { logger } from "@main/services";
export const HOSTER_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0";
export async function extractHosterFilename(
url: string,
directUrl?: string
): Promise<string> {
if (url.includes("#")) {
const fragment = url.split("#")[1];
if (fragment && !fragment.startsWith("http")) {
return fragment;
}
}
if (directUrl) {
try {
const response = await axios.head(directUrl, {
timeout: 10000,
headers: { "User-Agent": HOSTER_USER_AGENT },
});
const contentDisposition = response.headers["content-disposition"];
if (contentDisposition) {
const filenameMatch = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(
contentDisposition
);
if (filenameMatch && filenameMatch[1]) {
return filenameMatch[1].replace(/['"]/g, "");
}
}
} catch {
// Ignore errors
}
const urlPath = new URL(directUrl).pathname;
const filename = urlPath.split("/").pop()?.split("?")[0];
if (filename) {
return filename;
}
}
return "downloaded_file";
}
export function handleHosterError(error: unknown): never {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new Error("File not found");
}
if (error.response?.status === 429) {
throw new Error("Rate limit exceeded. Please try again later.");
}
if (error.response?.status === 403) {
throw new Error("Access denied. File may be private or deleted.");
}
throw new Error(`Network error: ${error.response?.status || "Unknown"}`);
}
throw error;
}
// ============================================
// FuckingFast API Class
// ============================================
export class FuckingFastApi {
private static readonly FUCKINGFAST_DOMAINS = ["fuckingfast.co"];
private static readonly FUCKINGFAST_REGEX =
/window\.open\("(https:\/\/fuckingfast\.co\/dl\/[^"]*)"\)/;
private static isSupportedDomain(url: string): boolean {
const lowerUrl = url.toLowerCase();
return this.FUCKINGFAST_DOMAINS.some((domain) => lowerUrl.includes(domain));
}
private static async getFuckingFastDirectLink(url: string): Promise<string> {
try {
logger.log(`[FuckingFast] Starting download link extraction for: ${url}`);
const response = await axios.get(url, {
headers: { "User-Agent": HOSTER_USER_AGENT },
timeout: 30000,
});
const html = response.data;
if (html.toLowerCase().includes("rate limit")) {
logger.error(`[FuckingFast] Rate limit detected`);
throw new Error(
"Rate limit exceeded. Please wait a few minutes and try again."
);
}
if (html.includes("File Not Found Or Deleted")) {
logger.error(`[FuckingFast] File not found or deleted`);
throw new Error("File not found or deleted");
}
const match = this.FUCKINGFAST_REGEX.exec(html);
if (!match || !match[1]) {
logger.error(`[FuckingFast] Could not extract download link`);
throw new Error("Could not extract download link from page");
}
logger.log(`[FuckingFast] Successfully extracted direct link`);
return match[1];
} catch (error) {
logger.error(`[FuckingFast] Error:`, error);
handleHosterError(error);
}
}
public static async getDirectLink(url: string): Promise<string> {
if (!this.isSupportedDomain(url)) {
throw new Error(
`Unsupported domain. Supported domains: ${this.FUCKINGFAST_DOMAINS.join(", ")}`
);
}
return this.getFuckingFastDirectLink(url);
}
public static async getFilename(
url: string,
directUrl?: string
): Promise<string> {
return extractHosterFilename(url, directUrl);
}
}

View File

@@ -3,3 +3,5 @@ export * from "./qiwi";
export * from "./datanodes";
export * from "./mediafire";
export * from "./pixeldrain";
export * from "./buzzheavier";
export * from "./fuckingfast";

View File

@@ -36,9 +36,9 @@ export class WindowManager {
private static initialConfigInitializationMainWindow: Electron.BrowserWindowConstructorOptions =
{
width: 1200,
height: 720,
height: 860,
minWidth: 1024,
minHeight: 540,
minHeight: 860,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
icon,
@@ -106,7 +106,7 @@ export class WindowManager {
valueEncoding: "json",
}
);
return data ?? { isMaximized: false, height: 720, width: 1200 };
return data ?? { isMaximized: false, height: 860, width: 1200 };
}
private static updateInitialConfig(
@@ -224,7 +224,7 @@ export class WindowManager {
? {
x: undefined,
y: undefined,
height: this.initialConfigInitializationMainWindow.height ?? 720,
height: this.initialConfigInitializationMainWindow.height ?? 860,
width: this.initialConfigInitializationMainWindow.width ?? 1200,
isMaximized: true,
}

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

@@ -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,6 +6,7 @@ import {
PencilIcon,
PersonAddIcon,
SignOutIcon,
TrophyIcon,
XCircleFillIcon,
} from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers";
@@ -27,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";
@@ -38,6 +40,7 @@ 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);
@@ -280,6 +283,13 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)}
/>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
<FullscreenMediaModal
visible={showFullscreenAvatar}
onClose={() => setShowFullscreenAvatar(false)}
@@ -393,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>

View File

@@ -8,6 +8,8 @@ export enum Downloader {
Mediafire,
TorBox,
Hydra,
Buzzheavier,
FuckingFast,
}
export enum DownloadSourceStatus {

View File

@@ -114,6 +114,16 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
if (uri.startsWith("https://www.mediafire.com"))
return [Downloader.Mediafire];
if (
uri.startsWith("https://buzzheavier.com") ||
uri.startsWith("https://bzzhr.co") ||
uri.startsWith("https://fuckingfast.net")
) {
return [Downloader.Buzzheavier];
}
if (uri.startsWith("https://fuckingfast.co")) {
return [Downloader.FuckingFast];
}
if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid];

View File

@@ -209,6 +209,7 @@ export interface UserProfile {
backupsPerGameLimit: number;
};
badges: string[];
hasCompletedWrapped2025: boolean;
}
export interface UpdateProfileRequest {