feat: add banner management features and translations

- Introduced new translations for banner actions including "Change banner", "Replace banner", "Remove banner", and confirmation prompts in English, Spanish, Portuguese, and Russian.
- Updated the UploadBackgroundImageButton component to support banner management with options to change, replace, or remove the banner.
- Implemented a confirmation modal for removing the banner.
- Enhanced user experience with animations for dropdown menus and button interactions.
- Removed deprecated Qiwi downloader support and added Rootz downloader integration.
This commit is contained in:
Chubby Granny Chaser
2026-01-11 17:13:54 +00:00
parent d9d443ee6d
commit 46e248c62a
26 changed files with 579 additions and 162 deletions

View File

@@ -716,8 +716,15 @@
"profile_reported": "Profile reported", "profile_reported": "Profile reported",
"your_friend_code": "Your friend code:", "your_friend_code": "Your friend code:",
"copy_friend_code": "Copy friend code", "copy_friend_code": "Copy friend code",
"copied": "Copied!",
"upload_banner": "Upload banner", "upload_banner": "Upload banner",
"uploading_banner": "Uploading banner…", "uploading_banner": "Uploading banner…",
"change_banner": "Change banner",
"replace_banner": "Replace banner",
"remove_banner": "Remove banner",
"remove_banner_modal_title": "Remove banner?",
"remove_banner_confirmation": "Are you sure you want to remove your banner? You can always pick a new one when you want.",
"remove": "Remove",
"background_image_updated": "Background image updated", "background_image_updated": "Background image updated",
"stats": "Stats", "stats": "Stats",
"achievements": "achievements", "achievements": "achievements",
@@ -738,9 +745,7 @@
"user_reviews": "Reviews", "user_reviews": "Reviews",
"delete_review": "Delete Review", "delete_review": "Delete Review",
"loading_reviews": "Loading reviews...", "loading_reviews": "Loading reviews...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025"
"view_my_wrapped_button": "View My Wrapped 2025",
"view_wrapped_button": "View {{displayName}}'s Wrapped 2025"
}, },
"library": { "library": {
"library": "Library", "library": "Library",

View File

@@ -702,8 +702,15 @@
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Tu código de amistad:", "your_friend_code": "Tu código de amistad:",
"copy_friend_code": "Copiar código de amistad", "copy_friend_code": "Copiar código de amistad",
"copied": "¡Copiado!",
"upload_banner": "Subir banner", "upload_banner": "Subir banner",
"uploading_banner": "Subiendo banner…", "uploading_banner": "Subiendo banner…",
"change_banner": "Cambiar banner",
"replace_banner": "Reemplazar banner",
"remove_banner": "Eliminar banner",
"remove_banner_modal_title": "¿Eliminar banner?",
"remove_banner_confirmation": "¿Estás seguro de que querés eliminar tu banner? Siempre podés elegir uno nuevo cuando quieras.",
"remove": "Eliminar",
"background_image_updated": "Imagen de fondo actualizada", "background_image_updated": "Imagen de fondo actualizada",
"stats": "Estadísticas", "stats": "Estadísticas",
"achievements": "logros", "achievements": "logros",
@@ -727,8 +734,6 @@
"user_reviews": "Reseñas", "user_reviews": "Reseñas",
"loading_reviews": "Cargando reseñas...", "loading_reviews": "Cargando reseñas...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Mi Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Sin reseñas aún", "no_reviews": "Sin reseñas aún",
"delete_review": "Eliminar reseña" "delete_review": "Eliminar reseña"
}, },

View File

@@ -707,8 +707,15 @@
"profile_reported": "Perfil reportado", "profile_reported": "Perfil reportado",
"your_friend_code": "Seu código de amigo:", "your_friend_code": "Seu código de amigo:",
"copy_friend_code": "Copiar código de amigo", "copy_friend_code": "Copiar código de amigo",
"copied": "Copiado!",
"upload_banner": "Carregar banner", "upload_banner": "Carregar banner",
"uploading_banner": "Carregando banner…", "uploading_banner": "Carregando banner…",
"change_banner": "Alterar banner",
"replace_banner": "Substituir banner",
"remove_banner": "Remover banner",
"remove_banner_modal_title": "Remover banner?",
"remove_banner_confirmation": "Tem certeza de que deseja remover seu banner? Você sempre pode escolher um novo quando quiser.",
"remove": "Remover",
"background_image_updated": "Imagem de fundo salva", "background_image_updated": "Imagem de fundo salva",
"stats": "Estatísticas", "stats": "Estatísticas",
"achievements": "conquistas", "achievements": "conquistas",
@@ -736,8 +743,6 @@
"user_reviews": "Avaliações", "user_reviews": "Avaliações",
"loading_reviews": "Carregando avaliações...", "loading_reviews": "Carregando avaliações...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Ver Meu Wrapped 2025",
"view_wrapped_button": "Ver Wrapped 2025 de {{displayName}}",
"no_reviews": "Ainda não há avaliações", "no_reviews": "Ainda não há avaliações",
"delete_review": "Excluir avaliação" "delete_review": "Excluir avaliação"
}, },

View File

@@ -702,8 +702,15 @@
"profile_reported": "Жалоба на профиль отправлена", "profile_reported": "Жалоба на профиль отправлена",
"your_friend_code": "Код вашего друга:", "your_friend_code": "Код вашего друга:",
"copy_friend_code": "Копировать код друга", "copy_friend_code": "Копировать код друга",
"copied": "Скопировано!",
"upload_banner": "Загрузить баннер", "upload_banner": "Загрузить баннер",
"uploading_banner": "Загрузка баннера...", "uploading_banner": "Загрузка баннера...",
"change_banner": "Изменить баннер",
"replace_banner": "Заменить баннер",
"remove_banner": "Удалить баннер",
"remove_banner_modal_title": "Удалить баннер?",
"remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.",
"remove": "Удалить",
"background_image_updated": "Фоновое изображение обновлено", "background_image_updated": "Фоновое изображение обновлено",
"stats": "Статистика", "stats": "Статистика",
"achievements": "Достижения", "achievements": "Достижения",
@@ -724,8 +731,6 @@
"user_reviews": "Отзывы", "user_reviews": "Отзывы",
"loading_reviews": "Загрузка отзывов...", "loading_reviews": "Загрузка отзывов...",
"wrapped_2025": "Wrapped 2025", "wrapped_2025": "Wrapped 2025",
"view_my_wrapped_button": "Просмотреть мой Wrapped 2025",
"view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}",
"no_reviews": "Пока нет отзывов", "no_reviews": "Пока нет отзывов",
"delete_review": "Удалить отзыв" "delete_review": "Удалить отзыв"
}, },

View File

@@ -0,0 +1,59 @@
import path from "node:path";
import fs from "node:fs";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const getGameInstallerActionType = async (
_event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string
): Promise<"install" | "open-folder"> => {
const downloadKey = levelKeys.game(shop, objectId);
const download = await downloadsSublevel.get(downloadKey);
if (!download?.folderName) return "open-folder";
const gamePath = path.join(
download.downloadPath ?? (await getDownloadsPath()),
download.folderName
);
if (!fs.existsSync(gamePath)) {
await downloadsSublevel.del(downloadKey);
return "open-folder";
}
// macOS always opens folder
if (process.platform === "darwin") {
return "open-folder";
}
// If path is a file, it will show in folder (open-folder behavior)
if (fs.lstatSync(gamePath).isFile()) {
return "open-folder";
}
// Check for setup.exe
const setupPath = path.join(gamePath, "setup.exe");
if (fs.existsSync(setupPath)) {
return "install";
}
// Check if there's exactly one .exe file
const gamePathFileNames = fs.readdirSync(gamePath);
const gamePathExecutableFiles = gamePathFileNames.filter(
(fileName: string) => path.extname(fileName).toLowerCase() === ".exe"
);
if (gamePathExecutableFiles.length === 1) {
return "install";
}
// Otherwise, opens folder
return "open-folder";
};
registerEvent("getGameInstallerActionType", getGameInstallerActionType);

View File

@@ -13,6 +13,7 @@ import "./delete-game-folder";
import "./extract-game-download"; import "./extract-game-download";
import "./get-default-wine-prefix-selection-path"; import "./get-default-wine-prefix-selection-path";
import "./get-game-by-object-id"; import "./get-game-by-object-id";
import "./get-game-installer-action-type";
import "./get-library"; import "./get-library";
import "./open-game-executable-path"; import "./open-game-executable-path";
import "./open-game-installer-path"; import "./open-game-installer-path";

View File

@@ -51,22 +51,30 @@ const updateProfile = async (
"backgroundImageUrl", "backgroundImageUrl",
]); ]);
if (updateProfile.profileImageUrl) { if (updateProfile.profileImageUrl !== undefined) {
const profileImageUrl = await uploadImage( if (updateProfile.profileImageUrl === null) {
"profile-image", payload["profileImageUrl"] = null;
updateProfile.profileImageUrl } else {
).catch(() => undefined); const profileImageUrl = await uploadImage(
"profile-image",
updateProfile.profileImageUrl
).catch(() => undefined);
payload["profileImageUrl"] = profileImageUrl; payload["profileImageUrl"] = profileImageUrl;
}
} }
if (updateProfile.backgroundImageUrl) { if (updateProfile.backgroundImageUrl !== undefined) {
const backgroundImageUrl = await uploadImage( if (updateProfile.backgroundImageUrl === null) {
"background-image", payload["backgroundImageUrl"] = null;
updateProfile.backgroundImageUrl } else {
).catch(() => undefined); const backgroundImageUrl = await uploadImage(
"background-image",
updateProfile.backgroundImageUrl
).catch(() => undefined);
payload["backgroundImageUrl"] = backgroundImageUrl; payload["backgroundImageUrl"] = backgroundImageUrl;
}
} }
return patchUserProfile(payload); return patchUserProfile(payload);

View File

@@ -4,11 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types"; import type { Download, DownloadProgress, UserPreferences } from "@types";
import { import {
GofileApi, GofileApi,
QiwiApi,
DatanodesApi, DatanodesApi,
MediafireApi, MediafireApi,
PixelDrainApi, PixelDrainApi,
VikingFileApi, VikingFileApi,
RootzApi,
} from "../hosters"; } from "../hosters";
import { PythonRPC } from "../python-rpc"; import { PythonRPC } from "../python-rpc";
import { import {
@@ -400,15 +400,6 @@ export class DownloadManager {
save_path: download.downloadPath, save_path: download.downloadPath,
}; };
} }
case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
case Downloader.Datanodes: { case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri);
return { return {
@@ -537,6 +528,15 @@ export class DownloadManager {
throw error; throw error;
} }
} }
case Downloader.Rootz: {
const downloadUrl = await RootzApi.getDownloadUrl(download.uri);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
};
}
} }
} }

View File

@@ -1,8 +1,8 @@
export * from "./gofile"; export * from "./gofile";
export * from "./qiwi";
export * from "./datanodes"; export * from "./datanodes";
export * from "./mediafire"; export * from "./mediafire";
export * from "./pixeldrain"; export * from "./pixeldrain";
export * from "./buzzheavier"; export * from "./buzzheavier";
export * from "./fuckingfast"; export * from "./fuckingfast";
export * from "./vikingfile"; export * from "./vikingfile";
export * from "./rootz";

View File

@@ -1,15 +0,0 @@
import { requestWebPage } from "@main/helpers";
export class QiwiApi {
public static async getDownloadUrl(url: string) {
const document = await requestWebPage(url);
const fileName = document.querySelector("h1")?.textContent;
const slug = url.split("/").pop();
const extension = fileName?.split(".").pop();
const downloadUrl = `https://spyderrock.com/${slug}.${extension}`;
return downloadUrl;
}
}

View File

@@ -0,0 +1,58 @@
import axios, { AxiosError } from "axios";
import { logger } from "../logger";
interface RootzApiResponse {
success: boolean;
data?: {
url: string;
fileName: string;
size: number;
mimeType: string;
expiresIn: number;
expiresAt: string | null;
downloads: number;
canDelete: boolean;
fileId: string;
isMirrored: boolean;
sourceService: string | null;
adsEnabled: boolean;
};
error?: string;
}
export class RootzApi {
public static async getDownloadUrl(uri: string): Promise<string> {
try {
const url = new URL(uri);
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length < 2 || pathSegments[0] !== "d") {
throw new Error("Invalid rootz URL format");
}
const id = pathSegments[1];
const apiUrl = `https://www.rootz.so/api/files/download-by-short/${id}`;
const response = await axios.get<RootzApiResponse>(apiUrl);
if (response.data.success && response.data.data?.url) {
return response.data.data.url;
}
throw new Error("Failed to get download URL from rootz API");
} catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<RootzApiResponse>;
if (axiosError.response?.status === 404) {
const errorMessage =
axiosError.response.data?.error || "File not found";
logger.error(`[Rootz] ${errorMessage}`);
throw new Error(errorMessage);
}
}
logger.error("[Rootz] Error fetching download URL:", error);
throw error;
}
}
}

View File

@@ -206,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", {
refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"),
openGameInstaller: (shop: GameShop, objectId: string) => openGameInstaller: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstaller", shop, objectId), ipcRenderer.invoke("openGameInstaller", shop, objectId),
getGameInstallerActionType: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("getGameInstallerActionType", shop, objectId),
openGameInstallerPath: (shop: GameShop, objectId: string) => openGameInstallerPath: (shop: GameShop, objectId: string) =>
ipcRenderer.invoke("openGameInstallerPath", shop, objectId), ipcRenderer.invoke("openGameInstallerPath", shop, objectId),
openGameExecutablePath: (shop: GameShop, objectId: string) => openGameExecutablePath: (shop: GameShop, objectId: string) =>

View File

@@ -8,6 +8,7 @@
min-width: 200px; min-width: 200px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
animation: dropdown-menu-fade-in 0.2s ease-out;
} }
&__group { &__group {
@@ -66,3 +67,14 @@
justify-content: center; justify-content: center;
} }
} }
@keyframes dropdown-menu-fade-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -224,21 +224,6 @@ export function Header() {
setActiveIndex(-1); setActiveIndex(-1);
}; };
useEffect(() => {
const prevPath = sessionStorage.getItem("prevPath");
const currentPath = location.pathname;
if (
prevPath?.startsWith("/catalogue") &&
!currentPath.startsWith("/catalogue") &&
catalogueSearchValue
) {
dispatch(setFilters({ title: "" }));
}
sessionStorage.setItem("prevPath", currentPath);
}, [location.pathname, catalogueSearchValue, dispatch]);
useEffect(() => { useEffect(() => {
if (!isDropdownVisible) return; if (!isDropdownVisible) return;

View File

@@ -7,7 +7,6 @@ export const DOWNLOADER_NAME = {
[Downloader.Torrent]: "Torrent", [Downloader.Torrent]: "Torrent",
[Downloader.Gofile]: "Gofile", [Downloader.Gofile]: "Gofile",
[Downloader.PixelDrain]: "PixelDrain", [Downloader.PixelDrain]: "PixelDrain",
[Downloader.Qiwi]: "Qiwi",
[Downloader.Datanodes]: "Datanodes", [Downloader.Datanodes]: "Datanodes",
[Downloader.Mediafire]: "Mediafire", [Downloader.Mediafire]: "Mediafire",
[Downloader.Buzzheavier]: "Buzzheavier", [Downloader.Buzzheavier]: "Buzzheavier",
@@ -15,6 +14,7 @@ export const DOWNLOADER_NAME = {
[Downloader.TorBox]: "TorBox", [Downloader.TorBox]: "TorBox",
[Downloader.Hydra]: "Nimbus", [Downloader.Hydra]: "Nimbus",
[Downloader.VikingFile]: "VikingFile", [Downloader.VikingFile]: "VikingFile",
[Downloader.Rootz]: "Rootz",
}; };
export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;

View File

@@ -167,6 +167,10 @@ declare global {
getLibrary: () => Promise<LibraryGame[]>; getLibrary: () => Promise<LibraryGame[]>;
refreshLibraryAssets: () => Promise<void>; refreshLibraryAssets: () => Promise<void>;
openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>; openGameInstaller: (shop: GameShop, objectId: string) => Promise<boolean>;
getGameInstallerActionType: (
shop: GameShop,
objectId: string
) => Promise<"install" | "open-folder">;
openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise<void>;
openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise<void>;
openGame: ( openGame: (

View File

@@ -511,6 +511,13 @@
min-height: unset; min-height: unset;
} }
&__simple-action-btn {
padding: calc(globals.$spacing-unit);
min-height: unset;
gap: calc(globals.$spacing-unit);
min-width: 120px;
}
&__progress-wrapper { &__progress-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -32,12 +32,12 @@ import {
FileDirectoryIcon, FileDirectoryIcon,
LinkIcon, LinkIcon,
PlayIcon, PlayIcon,
ThreeBarsIcon,
TrashIcon, TrashIcon,
UnlinkIcon, UnlinkIcon,
XCircleIcon, XCircleIcon,
GraphIcon, GraphIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { MoreVertical, Folder } from "lucide-react";
import { average } from "color.js"; import { average } from "color.js";
interface AnimatedPercentageProps { interface AnimatedPercentageProps {
@@ -452,6 +452,7 @@ export function DownloadGroup({
seedingStatus, seedingStatus,
}: Readonly<DownloadGroupProps>) { }: Readonly<DownloadGroupProps>) {
const { t } = useTranslation("downloads"); const { t } = useTranslation("downloads");
const { t: tGameDetails } = useTranslation("game_details");
const navigate = useNavigate(); const navigate = useNavigate();
const userPreferences = useAppSelector( const userPreferences = useAppSelector(
@@ -523,6 +524,9 @@ export function DownloadGroup({
const [optimisticallyResumed, setOptimisticallyResumed] = useState< const [optimisticallyResumed, setOptimisticallyResumed] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [gameActionTypes, setGameActionTypes] = useState<
Record<string, "install" | "open-folder">
>({});
const extractDominantColor = useCallback( const extractDominantColor = useCallback(
async (imageUrl: string, gameId: string) => { async (imageUrl: string, gameId: string) => {
@@ -770,6 +774,38 @@ export function DownloadGroup({
] ]
); );
// Fetch action types for completed games
useEffect(() => {
const fetchActionTypes = async () => {
const completedGames = library.filter(
(game) => game.download?.progress === 1
);
const actionTypesPromises = completedGames.map(async (game) => {
try {
const actionType =
await window.electron.getGameInstallerActionType(
game.shop,
game.objectId
);
return { gameId: game.id, actionType };
} catch {
return { gameId: game.id, actionType: "open-folder" as const };
}
});
const results = await Promise.all(actionTypesPromises);
const newActionTypes: Record<string, "install" | "open-folder"> = {};
results.forEach(({ gameId, actionType }) => {
newActionTypes[gameId] = actionType;
});
setGameActionTypes((prev) => ({ ...prev, ...newActionTypes }));
};
fetchActionTypes();
}, [library]);
if (!library.length) return null; if (!library.length) return null;
const isDownloadingGroup = title === t("download_in_progress"); const isDownloadingGroup = title === t("download_in_progress");
@@ -901,16 +937,31 @@ export function DownloadGroup({
)} )}
<div className="download-group__simple-actions"> <div className="download-group__simple-actions">
{game.download?.progress === 1 && ( {game.download?.progress === 1 && (() => {
<Button const actionType = gameActionTypes[game.id] || "open-folder";
theme="primary" const isInstall = actionType === "install";
onClick={() => openGameInstaller(game.shop, game.objectId)}
disabled={isGameDeleting(game.id)} return (
className="download-group__simple-menu-btn" <Button
> theme="primary"
<PlayIcon size={16} /> onClick={() => openGameInstaller(game.shop, game.objectId)}
</Button> disabled={isGameDeleting(game.id)}
)} className="download-group__simple-action-btn"
>
{isInstall ? (
<>
<DownloadIcon size={16} />
{t("install")}
</>
) : (
<>
<Folder size={16} />
{tGameDetails("open_folder")}
</>
)}
</Button>
);
})()}
{isQueuedGroup && game.download?.progress !== 1 && ( {isQueuedGroup && game.download?.progress !== 1 && (
<Button <Button
theme="primary" theme="primary"
@@ -926,7 +977,7 @@ export function DownloadGroup({
theme="outline" theme="outline"
className="download-group__simple-menu-btn" className="download-group__simple-menu-btn"
> >
<ThreeBarsIcon /> <MoreVertical size={16} />
</Button> </Button>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@@ -29,6 +29,12 @@
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
text-decoration: none; text-decoration: none;
} }
&--wrapped {
&:hover {
background-color: transparent;
}
}
} }
&__list-title { &__list-title {
@@ -70,4 +76,15 @@
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.4; line-height: 1.4;
} }
&__wrapped-link {
background: none;
border: none;
padding: 0;
text-align: start;
color: globals.$body-color;
font-size: 0.875rem;
cursor: pointer;
text-decoration: underline;
}
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext } from "react"; import { useCallback, useContext, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFormat, useUserDetails } from "@renderer/hooks"; import { useFormat, useUserDetails } from "@renderer/hooks";
@@ -7,9 +7,11 @@ import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
import { useSubscription } from "@renderer/hooks/use-subscription"; import { useSubscription } from "@renderer/hooks/use-subscription";
import { ClockIcon, TrophyIcon } from "@primer/octicons-react"; import { ClockIcon, TrophyIcon } from "@primer/octicons-react";
import { Award } from "lucide-react"; import { Award } from "lucide-react";
import { WrappedFullscreenModal } from "./wrapped-tab";
import "./user-stats-box.scss"; import "./user-stats-box.scss";
export function UserStatsBox() { export function UserStatsBox() {
const [showWrappedModal, setShowWrappedModal] = useState(false);
const { showHydraCloudModal } = useSubscription(); const { showHydraCloudModal } = useSubscription();
const { userStats, isMe, userProfile } = useContext(userProfileContext); const { userStats, isMe, userProfile } = useContext(userProfileContext);
const { userDetails } = useUserDetails(); const { userDetails } = useUserDetails();
@@ -41,6 +43,18 @@ export function UserStatsBox() {
return ( return (
<div className="user-stats__box"> <div className="user-stats__box">
<ul className="user-stats__list"> <ul className="user-stats__list">
{userProfile?.hasCompletedWrapped2025 && (
<li className="user-stats__list-item user-stats__list-item--wrapped">
<button
type="button"
onClick={() => setShowWrappedModal(true)}
className="user-stats__wrapped-link"
>
Wrapped 2025
</button>
</li>
)}
{(isMe || userStats.unlockedAchievementSum !== undefined) && ( {(isMe || userStats.unlockedAchievementSum !== undefined) && (
<li className="user-stats__list-item"> <li className="user-stats__list-item">
<h3 className="user-stats__list-title"> <h3 className="user-stats__list-title">
@@ -126,6 +140,14 @@ export function UserStatsBox() {
</li> </li>
)} )}
</ul> </ul>
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
</div> </div>
); );
} }

View File

@@ -144,11 +144,6 @@
} }
} }
&__left-actions {
display: flex;
gap: globals.$spacing-unit;
}
&__actions { &__actions {
display: flex; display: flex;
gap: globals.$spacing-unit; gap: globals.$spacing-unit;
@@ -160,35 +155,5 @@
&--outline { &--outline {
border-color: globals.$body-color; 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

@@ -7,7 +7,6 @@ import {
PencilIcon, PencilIcon,
PersonAddIcon, PersonAddIcon,
SignOutIcon, SignOutIcon,
TrophyIcon,
XCircleFillIcon, XCircleFillIcon,
} from "@primer/octicons-react"; } from "@primer/octicons-react";
import { buildGameDetailsPath } from "@renderer/helpers"; import { buildGameDetailsPath } from "@renderer/helpers";
@@ -30,7 +29,6 @@ import { motion } from "framer-motion";
import type { FriendRequestAction } from "@types"; import type { FriendRequestAction } from "@types";
import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal"; import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal";
import { WrappedFullscreenModal } from "../profile-content/wrapped-tab";
import Skeleton from "react-loading-skeleton"; import Skeleton from "react-loading-skeleton";
import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button"; import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button";
import "./profile-hero.scss"; import "./profile-hero.scss";
@@ -41,10 +39,10 @@ type FriendAction =
export function ProfileHero() { export function ProfileHero() {
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
const [showWrappedModal, setShowWrappedModal] = useState(false);
const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false); const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false);
const [isPerformingAction, setIsPerformingAction] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false);
const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false); const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } = const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } =
useContext(userProfileContext); useContext(userProfileContext);
@@ -261,9 +259,23 @@ export function ProfileHero() {
const copyFriendCode = useCallback(() => { const copyFriendCode = useCallback(() => {
if (userProfile?.id) { if (userProfile?.id) {
navigator.clipboard.writeText(userProfile.id); navigator.clipboard.writeText(userProfile.id);
showSuccessToast(t("friend_code_copied")); setIsCopied(true);
const startTime = performance.now();
const duration = 1200; // 1.2 seconds
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
if (elapsed < duration) {
requestAnimationFrame(animate);
} else {
setIsCopied(false);
}
};
requestAnimationFrame(animate);
} }
}, [userProfile, showSuccessToast, t]); }, [userProfile]);
const currentGame = useMemo(() => { const currentGame = useMemo(() => {
if (isMe) { if (isMe) {
@@ -286,13 +298,6 @@ export function ProfileHero() {
onClose={() => setShowEditProfileModal(false)} onClose={() => setShowEditProfileModal(false)}
/> />
{userProfile && (
<WrappedFullscreenModal
userId={userProfile.id}
isOpen={showWrappedModal}
onClose={() => setShowWrappedModal(false)}
/>
)}
<FullscreenMediaModal <FullscreenMediaModal
visible={showFullscreenAvatar} visible={showFullscreenAvatar}
onClose={() => setShowFullscreenAvatar(false)} onClose={() => setShowFullscreenAvatar(false)}
@@ -348,7 +353,7 @@ export function ProfileHero() {
onMouseLeave={() => setIsCopyButtonHovered(false)} onMouseLeave={() => setIsCopyButtonHovered(false)}
initial={{ width: 28 }} initial={{ width: 28 }}
animate={{ animate={{
width: isCopyButtonHovered ? 105 : 28, width: isCopyButtonHovered || isCopied ? 105 : 28,
}} }}
transition={{ duration: 0.2, ease: "easeInOut" }} transition={{ duration: 0.2, ease: "easeInOut" }}
> >
@@ -356,12 +361,12 @@ export function ProfileHero() {
className="profile-hero__friend-code" className="profile-hero__friend-code"
initial={{ opacity: 0, marginRight: 0 }} initial={{ opacity: 0, marginRight: 0 }}
animate={{ animate={{
opacity: isCopyButtonHovered ? 1 : 0, opacity: isCopyButtonHovered || isCopied ? 1 : 0,
marginRight: isCopyButtonHovered ? 8 : 0, marginRight: isCopyButtonHovered || isCopied ? 8 : 0,
}} }}
transition={{ duration: 0.2, ease: "easeInOut" }} transition={{ duration: 0.2, ease: "easeInOut" }}
> >
{userProfile?.id} {isCopied ? t("copied") : userProfile?.id}
</motion.span> </motion.span>
<CopyIcon size={16} /> <CopyIcon size={16} />
</motion.button> </motion.button>
@@ -410,22 +415,6 @@ export function ProfileHero() {
background: !backgroundImage ? heroBackground : undefined, 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 className="profile-hero__actions">{profileActions}</div>
</div> </div>
</section> </section>

View File

@@ -1,11 +1,86 @@
@use "../../../scss/globals.scss"; @use "../../../scss/globals.scss";
.upload-background-image-button { .upload-background-image-button {
position: absolute; &__wrapper {
top: 16px; position: absolute;
right: 16px; top: 16px;
right: 16px;
}
border-color: globals.$body-color; border-color: globals.$body-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
&__menu {
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
min-width: 180px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 1000;
padding: 4px;
display: flex;
flex-direction: column;
animation: menu-fade-in 0.2s ease-out;
&--closing {
animation: menu-fade-out 0.15s ease-in;
}
}
&__menu-item {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
border-radius: 4px;
padding: 5px 12px;
cursor: pointer;
transition: background-color 0.1s ease-in-out;
font-size: 14px;
background: none;
border: none;
color: globals.$body-color;
text-align: left;
&:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.1);
}
&:disabled {
cursor: default;
opacity: 0.6;
}
&:focus {
background-color: rgba(255, 255, 255, 0.1);
outline: none;
}
}
}
@keyframes menu-fade-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes menu-fade-out {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-8px);
}
} }

View File

@@ -1,6 +1,8 @@
import { UploadIcon } from "@primer/octicons-react"; import { TrashIcon, UploadIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components"; import { MoreVertical } from "lucide-react";
import { useContext, useState } from "react"; import { Button, ConfirmationModal } from "@renderer/components";
import { createPortal } from "react-dom";
import { useContext, useEffect, useRef, useState } from "react";
import { userProfileContext } from "@renderer/context"; import { userProfileContext } from "@renderer/context";
import { useToast, useUserDetails } from "@renderer/hooks"; import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,16 +11,33 @@ import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() { export function UploadBackgroundImageButton() {
const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] = const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] =
useState(false); useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { hasActiveSubscription } = useUserDetails(); const { hasActiveSubscription } = useUserDetails();
const { t } = useTranslation("user_profile"); const { t } = useTranslation("user_profile");
const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext); const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } =
useContext(userProfileContext);
const { patchUser, fetchUserDetails } = useUserDetails(); const { patchUser, fetchUserDetails } = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const handleChangeCoverClick = async () => { const hasBanner = !!userProfile?.backgroundImageUrl;
const closeMenu = () => {
setIsMenuClosing(true);
setTimeout(() => {
setIsMenuOpen(false);
setIsMenuClosing(false);
}, 150);
};
const handleReplaceBanner = async () => {
closeMenu();
try { try {
const { filePaths } = await window.electron.showOpenDialog({ const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"], properties: ["openFile"],
@@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() {
showSuccessToast(t("background_image_updated")); showSuccessToast(t("background_image_updated"));
await fetchUserDetails(); await fetchUserDetails();
await getUserProfile();
} }
} finally { } finally {
setIsUploadingBackgorundImage(false); setIsUploadingBackgorundImage(false);
} }
}; };
const handleRemoveBannerClick = () => {
closeMenu();
setShowRemoveBannerModal(true);
};
const handleRemoveBannerConfirm = async () => {
setShowRemoveBannerModal(false);
try {
setIsUploadingBackgorundImage(true);
setSelectedBackgroundImage("");
await patchUser({ backgroundImageUrl: null });
showSuccessToast(t("background_image_updated"));
await fetchUserDetails();
await getUserProfile();
} finally {
setIsUploadingBackgorundImage(false);
}
};
// Handle click outside, scroll, and escape key to close menu
useEffect(() => {
if (!isMenuOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
buttonRef.current &&
!buttonRef.current.contains(target)
) {
closeMenu();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
}
};
const handleScroll = () => {
closeMenu();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("scroll", handleScroll, true);
};
}, [isMenuOpen]);
if (!isMe || !hasActiveSubscription) return null; if (!isMe || !hasActiveSubscription) return null;
return ( // If no banner exists, show the original upload button
<Button if (!hasBanner) {
theme="outline" return (
className="upload-background-image-button" <div className="upload-background-image-button__wrapper">
onClick={handleChangeCoverClick} <Button
disabled={isUploadingBackgroundImage} theme="outline"
className="upload-background-image-button"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon />
{isUploadingBackgroundImage
? t("uploading_banner")
: t("upload_banner")}
</Button>
</div>
);
}
// Calculate menu position
const getMenuPosition = () => {
if (!buttonRef.current) return { top: 0, right: 0 };
const rect = buttonRef.current.getBoundingClientRect();
return {
top: rect.bottom + 5,
right: window.innerWidth - rect.right,
};
};
const menuPosition = isMenuOpen ? getMenuPosition() : { top: 0, right: 0 };
const menuContent = isMenuOpen && (
<div
ref={menuRef}
className={`upload-background-image-button__menu ${
isMenuClosing ? "upload-background-image-button__menu--closing" : ""
}`}
style={{
position: "fixed",
top: `${menuPosition.top}px`,
right: `${menuPosition.right}px`,
}}
> >
<UploadIcon /> <button
{isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")} type="button"
</Button> className="upload-background-image-button__menu-item"
onClick={handleReplaceBanner}
disabled={isUploadingBackgroundImage}
>
<UploadIcon size={16} />
{t("replace_banner")}
</button>
<button
type="button"
className="upload-background-image-button__menu-item"
onClick={handleRemoveBannerClick}
disabled={isUploadingBackgroundImage}
>
<TrashIcon size={16} />
{t("remove_banner")}
</button>
</div>
);
return (
<>
<div ref={buttonRef} className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
disabled={isUploadingBackgroundImage}
>
{t("change_banner")}
<MoreVertical size={16} />
</Button>
</div>
{createPortal(menuContent, document.body)}
<ConfirmationModal
visible={showRemoveBannerModal}
title={t("remove_banner_modal_title")}
descriptionText={t("remove_banner_confirmation")}
onClose={() => setShowRemoveBannerModal(false)}
onConfirm={handleRemoveBannerConfirm}
cancelButtonLabel={t("cancel")}
confirmButtonLabel={t("remove")}
buttonsIsDisabled={isUploadingBackgroundImage}
/>
</>
); );
} }

View File

@@ -3,7 +3,6 @@ export enum Downloader {
Torrent, Torrent,
Gofile, Gofile,
PixelDrain, PixelDrain,
Qiwi,
Datanodes, Datanodes,
Mediafire, Mediafire,
TorBox, TorBox,
@@ -11,6 +10,7 @@ export enum Downloader {
Buzzheavier, Buzzheavier,
FuckingFast, FuckingFast,
VikingFile, VikingFile,
Rootz,
} }
export enum DownloadSourceStatus { export enum DownloadSourceStatus {

View File

@@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile]; if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile];
if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain]; if (uri.startsWith("https://pixeldrain.com")) return [Downloader.PixelDrain];
if (uri.startsWith("https://qiwi.gg")) return [Downloader.Qiwi];
if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes]; if (uri.startsWith("https://datanodes.to")) return [Downloader.Datanodes];
if (uri.startsWith("https://www.mediafire.com")) if (uri.startsWith("https://www.mediafire.com"))
return [Downloader.Mediafire]; return [Downloader.Mediafire];
@@ -127,6 +126,9 @@ export const getDownloadersForUri = (uri: string) => {
if (uri.startsWith("https://vikingfile.com")) { if (uri.startsWith("https://vikingfile.com")) {
return [Downloader.VikingFile]; return [Downloader.VikingFile];
} }
if (uri.startsWith("https://www.rootz.so")) {
return [Downloader.Rootz];
}
if (realDebridHosts.some((host) => uri.startsWith(host))) if (realDebridHosts.some((host) => uri.startsWith(host)))
return [Downloader.RealDebrid]; return [Downloader.RealDebrid];