mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-15 08:23:02 -03:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": "Удалить отзыв"
|
||||||
},
|
},
|
||||||
|
|||||||
59
src/main/events/library/get-game-installer-action-type.ts
Normal file
59
src/main/events/library/get-game-installer-action-type.ts
Normal 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);
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
58
src/main/services/hosters/rootz.ts
Normal file
58
src/main/services/hosters/rootz.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
4
src/renderer/src/declaration.d.ts
vendored
4
src/renderer/src/declaration.d.ts
vendored
@@ -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: (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user