diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0aaf2c09..1b69bc82 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -717,8 +717,15 @@ "profile_reported": "Profile reported", "your_friend_code": "Your friend code:", "copy_friend_code": "Copy friend code", + "copied": "Copied!", "upload_banner": "Upload 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", "stats": "Stats", "achievements": "achievements", @@ -739,9 +746,7 @@ "user_reviews": "Reviews", "delete_review": "Delete Review", "loading_reviews": "Loading reviews...", - "wrapped_2025": "Wrapped 2025", - "view_my_wrapped_button": "View My Wrapped 2025", - "view_wrapped_button": "View {{displayName}}'s Wrapped 2025" + "wrapped_2025": "Wrapped 2025" }, "library": { "library": "Library", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 666dd065..cce710ca 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -702,8 +702,15 @@ "profile_reported": "Perfil reportado", "your_friend_code": "Tu código de amistad:", "copy_friend_code": "Copiar código de amistad", + "copied": "¡Copiado!", "upload_banner": "Subir 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", "stats": "Estadísticas", "achievements": "logros", @@ -727,8 +734,6 @@ "user_reviews": "Reseñas", "loading_reviews": "Cargando reseñas...", "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", "delete_review": "Eliminar reseña" }, diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index ee5ef5dd..d3eaa995 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -707,8 +707,15 @@ "profile_reported": "Perfil reportado", "your_friend_code": "Seu código de amigo:", "copy_friend_code": "Copiar código de amigo", + "copied": "Copiado!", "upload_banner": "Carregar 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", "stats": "Estatísticas", "achievements": "conquistas", @@ -736,8 +743,6 @@ "user_reviews": "Avaliações", "loading_reviews": "Carregando avaliações...", "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", "delete_review": "Excluir avaliação" }, diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index ff863617..bb0bc6ce 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -702,8 +702,15 @@ "profile_reported": "Жалоба на профиль отправлена", "your_friend_code": "Код вашего друга:", "copy_friend_code": "Копировать код друга", + "copied": "Скопировано!", "upload_banner": "Загрузить баннер", "uploading_banner": "Загрузка баннера...", + "change_banner": "Изменить баннер", + "replace_banner": "Заменить баннер", + "remove_banner": "Удалить баннер", + "remove_banner_modal_title": "Удалить баннер?", + "remove_banner_confirmation": "Вы уверены, что хотите удалить свой баннер? Вы всегда можете выбрать новый, когда захотите.", + "remove": "Удалить", "background_image_updated": "Фоновое изображение обновлено", "stats": "Статистика", "achievements": "Достижения", @@ -724,8 +731,6 @@ "user_reviews": "Отзывы", "loading_reviews": "Загрузка отзывов...", "wrapped_2025": "Wrapped 2025", - "view_my_wrapped_button": "Просмотреть мой Wrapped 2025", - "view_wrapped_button": "Просмотреть Wrapped 2025 {{displayName}}", "no_reviews": "Пока нет отзывов", "delete_review": "Удалить отзыв" }, diff --git a/src/main/events/library/get-game-installer-action-type.ts b/src/main/events/library/get-game-installer-action-type.ts new file mode 100644 index 00000000..2e58968a --- /dev/null +++ b/src/main/events/library/get-game-installer-action-type.ts @@ -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); diff --git a/src/main/events/library/index.ts b/src/main/events/library/index.ts index 75fc5cd9..1e4db10d 100644 --- a/src/main/events/library/index.ts +++ b/src/main/events/library/index.ts @@ -13,6 +13,7 @@ import "./delete-game-folder"; import "./extract-game-download"; import "./get-default-wine-prefix-selection-path"; import "./get-game-by-object-id"; +import "./get-game-installer-action-type"; import "./get-library"; import "./open-game-executable-path"; import "./open-game-installer-path"; diff --git a/src/main/events/profile/update-profile.ts b/src/main/events/profile/update-profile.ts index f5a04f0d..1f0a0f95 100644 --- a/src/main/events/profile/update-profile.ts +++ b/src/main/events/profile/update-profile.ts @@ -51,22 +51,30 @@ const updateProfile = async ( "backgroundImageUrl", ]); - if (updateProfile.profileImageUrl) { - const profileImageUrl = await uploadImage( - "profile-image", - updateProfile.profileImageUrl - ).catch(() => undefined); + if (updateProfile.profileImageUrl !== undefined) { + if (updateProfile.profileImageUrl === null) { + payload["profileImageUrl"] = null; + } else { + const profileImageUrl = await uploadImage( + "profile-image", + updateProfile.profileImageUrl + ).catch(() => undefined); - payload["profileImageUrl"] = profileImageUrl; + payload["profileImageUrl"] = profileImageUrl; + } } - if (updateProfile.backgroundImageUrl) { - const backgroundImageUrl = await uploadImage( - "background-image", - updateProfile.backgroundImageUrl - ).catch(() => undefined); + if (updateProfile.backgroundImageUrl !== undefined) { + if (updateProfile.backgroundImageUrl === null) { + payload["backgroundImageUrl"] = null; + } else { + const backgroundImageUrl = await uploadImage( + "background-image", + updateProfile.backgroundImageUrl + ).catch(() => undefined); - payload["backgroundImageUrl"] = backgroundImageUrl; + payload["backgroundImageUrl"] = backgroundImageUrl; + } } return patchUserProfile(payload); diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index bc6746e2..86bb15cf 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -4,11 +4,11 @@ import { publishDownloadCompleteNotification } from "../notifications"; import type { Download, DownloadProgress, UserPreferences } from "@types"; import { GofileApi, - QiwiApi, DatanodesApi, MediafireApi, PixelDrainApi, VikingFileApi, + RootzApi, } from "../hosters"; import { PythonRPC } from "../python-rpc"; import { @@ -400,15 +400,6 @@ export class DownloadManager { 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: { const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri); return { @@ -537,6 +528,15 @@ export class DownloadManager { throw error; } } + case Downloader.Rootz: { + const downloadUrl = await RootzApi.getDownloadUrl(download.uri); + return { + action: "start", + game_id: downloadId, + url: downloadUrl, + save_path: download.downloadPath, + }; + } } } diff --git a/src/main/services/hosters/index.ts b/src/main/services/hosters/index.ts index e22fb680..d4e1b09c 100644 --- a/src/main/services/hosters/index.ts +++ b/src/main/services/hosters/index.ts @@ -1,8 +1,8 @@ export * from "./gofile"; -export * from "./qiwi"; export * from "./datanodes"; export * from "./mediafire"; export * from "./pixeldrain"; export * from "./buzzheavier"; export * from "./fuckingfast"; export * from "./vikingfile"; +export * from "./rootz"; diff --git a/src/main/services/hosters/qiwi.ts b/src/main/services/hosters/qiwi.ts deleted file mode 100644 index e18b011c..00000000 --- a/src/main/services/hosters/qiwi.ts +++ /dev/null @@ -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; - } -} diff --git a/src/main/services/hosters/rootz.ts b/src/main/services/hosters/rootz.ts new file mode 100644 index 00000000..cb4658fc --- /dev/null +++ b/src/main/services/hosters/rootz.ts @@ -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 { + 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(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; + 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; + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 32bc0f88..dd7497bf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -206,6 +206,8 @@ contextBridge.exposeInMainWorld("electron", { refreshLibraryAssets: () => ipcRenderer.invoke("refreshLibraryAssets"), openGameInstaller: (shop: GameShop, objectId: string) => ipcRenderer.invoke("openGameInstaller", shop, objectId), + getGameInstallerActionType: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("getGameInstallerActionType", shop, objectId), openGameInstallerPath: (shop: GameShop, objectId: string) => ipcRenderer.invoke("openGameInstallerPath", shop, objectId), openGameExecutablePath: (shop: GameShop, objectId: string) => diff --git a/src/renderer/src/components/dropdown-menu/dropdown-menu.scss b/src/renderer/src/components/dropdown-menu/dropdown-menu.scss index 0f73c608..7406a483 100644 --- a/src/renderer/src/components/dropdown-menu/dropdown-menu.scss +++ b/src/renderer/src/components/dropdown-menu/dropdown-menu.scss @@ -8,6 +8,7 @@ min-width: 200px; flex-direction: column; align-items: center; + animation: dropdown-menu-fade-in 0.2s ease-out; } &__group { @@ -66,3 +67,14 @@ justify-content: center; } } + +@keyframes dropdown-menu-fade-in { + 0% { + opacity: 0; + transform: translateY(-8px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/renderer/src/components/header/header.tsx b/src/renderer/src/components/header/header.tsx index 5c058252..acaec9f1 100644 --- a/src/renderer/src/components/header/header.tsx +++ b/src/renderer/src/components/header/header.tsx @@ -224,21 +224,6 @@ export function Header() { 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(() => { if (!isDropdownVisible) return; diff --git a/src/renderer/src/constants.ts b/src/renderer/src/constants.ts index d227969b..b41938b0 100644 --- a/src/renderer/src/constants.ts +++ b/src/renderer/src/constants.ts @@ -7,7 +7,6 @@ export const DOWNLOADER_NAME = { [Downloader.Torrent]: "Torrent", [Downloader.Gofile]: "Gofile", [Downloader.PixelDrain]: "PixelDrain", - [Downloader.Qiwi]: "Qiwi", [Downloader.Datanodes]: "Datanodes", [Downloader.Mediafire]: "Mediafire", [Downloader.Buzzheavier]: "Buzzheavier", @@ -15,6 +14,7 @@ export const DOWNLOADER_NAME = { [Downloader.TorBox]: "TorBox", [Downloader.Hydra]: "Nimbus", [Downloader.VikingFile]: "VikingFile", + [Downloader.Rootz]: "Rootz", }; export const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 4e7fd245..a2cc6ccf 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -167,6 +167,10 @@ declare global { getLibrary: () => Promise; refreshLibraryAssets: () => Promise; openGameInstaller: (shop: GameShop, objectId: string) => Promise; + getGameInstallerActionType: ( + shop: GameShop, + objectId: string + ) => Promise<"install" | "open-folder">; openGameInstallerPath: (shop: GameShop, objectId: string) => Promise; openGameExecutablePath: (shop: GameShop, objectId: string) => Promise; openGame: ( diff --git a/src/renderer/src/pages/downloads/download-group.scss b/src/renderer/src/pages/downloads/download-group.scss index bfd8fbda..76ec8958 100644 --- a/src/renderer/src/pages/downloads/download-group.scss +++ b/src/renderer/src/pages/downloads/download-group.scss @@ -511,6 +511,13 @@ min-height: unset; } + &__simple-action-btn { + padding: calc(globals.$spacing-unit); + min-height: unset; + gap: calc(globals.$spacing-unit); + min-width: 120px; + } + &__progress-wrapper { flex: 1; display: flex; diff --git a/src/renderer/src/pages/downloads/download-group.tsx b/src/renderer/src/pages/downloads/download-group.tsx index 6a22148a..ce26a4b9 100644 --- a/src/renderer/src/pages/downloads/download-group.tsx +++ b/src/renderer/src/pages/downloads/download-group.tsx @@ -32,12 +32,12 @@ import { FileDirectoryIcon, LinkIcon, PlayIcon, - ThreeBarsIcon, TrashIcon, UnlinkIcon, XCircleIcon, GraphIcon, } from "@primer/octicons-react"; +import { MoreVertical, Folder } from "lucide-react"; import { average } from "color.js"; interface AnimatedPercentageProps { @@ -452,6 +452,7 @@ export function DownloadGroup({ seedingStatus, }: Readonly) { const { t } = useTranslation("downloads"); + const { t: tGameDetails } = useTranslation("game_details"); const navigate = useNavigate(); const userPreferences = useAppSelector( @@ -523,6 +524,9 @@ export function DownloadGroup({ const [optimisticallyResumed, setOptimisticallyResumed] = useState< Record >({}); + const [gameActionTypes, setGameActionTypes] = useState< + Record + >({}); const extractDominantColor = useCallback( async (imageUrl: string, gameId: string) => { @@ -770,6 +774,37 @@ 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 = {}; + results.forEach(({ gameId, actionType }) => { + newActionTypes[gameId] = actionType; + }); + + setGameActionTypes((prev) => ({ ...prev, ...newActionTypes })); + }; + + fetchActionTypes(); + }, [library]); + if (!library.length) return null; const isDownloadingGroup = title === t("download_in_progress"); @@ -901,16 +936,35 @@ export function DownloadGroup({ )}
- {game.download?.progress === 1 && ( - - )} + {game.download?.progress === 1 && + (() => { + const actionType = + gameActionTypes[game.id] || "open-folder"; + const isInstall = actionType === "install"; + + return ( + + ); + })()} {isQueuedGroup && game.download?.progress !== 1 && (
diff --git a/src/renderer/src/pages/profile/profile-content/user-stats-box.scss b/src/renderer/src/pages/profile/profile-content/user-stats-box.scss index 72a4d580..a93153ac 100644 --- a/src/renderer/src/pages/profile/profile-content/user-stats-box.scss +++ b/src/renderer/src/pages/profile/profile-content/user-stats-box.scss @@ -29,6 +29,12 @@ background-color: rgba(255, 255, 255, 0.15); text-decoration: none; } + + &--wrapped { + &:hover { + background-color: transparent; + } + } } &__list-title { @@ -70,4 +76,15 @@ font-size: 0.75rem; 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; + } } diff --git a/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx b/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx index 8b61cdd6..9bdbd648 100644 --- a/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx +++ b/src/renderer/src/pages/profile/profile-content/user-stats-box.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext } from "react"; +import { useCallback, useContext, useState } from "react"; import { userProfileContext } from "@renderer/context"; import { useTranslation } from "react-i18next"; 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 { ClockIcon, TrophyIcon } from "@primer/octicons-react"; import { Award } from "lucide-react"; +import { WrappedFullscreenModal } from "./wrapped-tab"; import "./user-stats-box.scss"; export function UserStatsBox() { + const [showWrappedModal, setShowWrappedModal] = useState(false); const { showHydraCloudModal } = useSubscription(); const { userStats, isMe, userProfile } = useContext(userProfileContext); const { userDetails } = useUserDetails(); @@ -41,6 +43,18 @@ export function UserStatsBox() { return (
    + {userProfile?.hasCompletedWrapped2025 && ( +
  • + +
  • + )} + {(isMe || userStats.unlockedAchievementSum !== undefined) && (
  • @@ -126,6 +140,14 @@ export function UserStatsBox() {

  • )}
+ + {userProfile && ( + setShowWrappedModal(false)} + /> + )}
); } diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.scss b/src/renderer/src/pages/profile/profile-hero/profile-hero.scss index e3459823..7061eceb 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.scss +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.scss @@ -144,11 +144,6 @@ } } - &__left-actions { - display: flex; - gap: globals.$spacing-unit; - } - &__actions { display: flex; gap: globals.$spacing-unit; @@ -160,35 +155,5 @@ &--outline { border-color: globals.$body-color; } - - &--wrapped { - background: linear-gradient( - 120deg, - #2a57ff 0%, - #2951e6 11%, - #2f5bff 16%, - #2c56e8 29%, - #244acc 34%, - #2245c2 40%, - #3a6bff 45%, - #3766f2 50%, - #2444b8 56%, - #122a73 82%, - #2348b3 86%, - #1f429e 87%, - #10286a 93%, - #0e2a63 100% - ); - background-color: #2a57ff; - background-size: 105% 100%; - background-position: 100% 50%; - border: none; - color: white; - transition: background-position 0.4s ease; - - &:hover { - background-position: 0% 50%; - } - } } } diff --git a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx index 6fafc95e..fc31de76 100644 --- a/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx +++ b/src/renderer/src/pages/profile/profile-hero/profile-hero.tsx @@ -7,7 +7,6 @@ import { PencilIcon, PersonAddIcon, SignOutIcon, - TrophyIcon, XCircleFillIcon, } from "@primer/octicons-react"; import { buildGameDetailsPath } from "@renderer/helpers"; @@ -30,7 +29,6 @@ import { motion } from "framer-motion"; import type { FriendRequestAction } from "@types"; import { EditProfileModal } from "../edit-profile-modal/edit-profile-modal"; -import { WrappedFullscreenModal } from "../profile-content/wrapped-tab"; import Skeleton from "react-loading-skeleton"; import { UploadBackgroundImageButton } from "../upload-background-image-button/upload-background-image-button"; import "./profile-hero.scss"; @@ -41,10 +39,10 @@ type FriendAction = export function ProfileHero() { const [showEditProfileModal, setShowEditProfileModal] = useState(false); - const [showWrappedModal, setShowWrappedModal] = useState(false); const [showFullscreenAvatar, setShowFullscreenAvatar] = useState(false); const [isPerformingAction, setIsPerformingAction] = useState(false); const [isCopyButtonHovered, setIsCopyButtonHovered] = useState(false); + const [isCopied, setIsCopied] = useState(false); const { isMe, getUserProfile, userProfile, heroBackground, backgroundImage } = useContext(userProfileContext); @@ -261,9 +259,23 @@ export function ProfileHero() { const copyFriendCode = useCallback(() => { if (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(() => { if (isMe) { @@ -286,13 +298,6 @@ export function ProfileHero() { onClose={() => setShowEditProfileModal(false)} /> - {userProfile && ( - setShowWrappedModal(false)} - /> - )} setShowFullscreenAvatar(false)} @@ -348,7 +353,7 @@ export function ProfileHero() { onMouseLeave={() => setIsCopyButtonHovered(false)} initial={{ width: 28 }} animate={{ - width: isCopyButtonHovered ? 105 : 28, + width: isCopyButtonHovered || isCopied ? 105 : 28, }} transition={{ duration: 0.2, ease: "easeInOut" }} > @@ -356,12 +361,12 @@ export function ProfileHero() { className="profile-hero__friend-code" initial={{ opacity: 0, marginRight: 0 }} animate={{ - opacity: isCopyButtonHovered ? 1 : 0, - marginRight: isCopyButtonHovered ? 8 : 0, + opacity: isCopyButtonHovered || isCopied ? 1 : 0, + marginRight: isCopyButtonHovered || isCopied ? 8 : 0, }} transition={{ duration: 0.2, ease: "easeInOut" }} > - {userProfile?.id} + {isCopied ? t("copied") : userProfile?.id} @@ -410,22 +415,6 @@ export function ProfileHero() { background: !backgroundImage ? heroBackground : undefined, }} > - {userProfile?.hasCompletedWrapped2025 && ( -
- -
- )}
{profileActions}
diff --git a/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.scss b/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.scss index e43cad6c..d7cd5ace 100644 --- a/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.scss +++ b/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.scss @@ -1,11 +1,86 @@ @use "../../../scss/globals.scss"; .upload-background-image-button { - position: absolute; - top: 16px; - right: 16px; + &__wrapper { + position: absolute; + top: 16px; + right: 16px; + } + border-color: globals.$body-color; box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.1); 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); + } } diff --git a/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.tsx b/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.tsx index 33f90692..c0347712 100644 --- a/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.tsx +++ b/src/renderer/src/pages/profile/upload-background-image-button/upload-background-image-button.tsx @@ -1,6 +1,8 @@ -import { UploadIcon } from "@primer/octicons-react"; -import { Button } from "@renderer/components"; -import { useContext, useState } from "react"; +import { TrashIcon, UploadIcon } from "@primer/octicons-react"; +import { MoreVertical } from "lucide-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 { useToast, useUserDetails } from "@renderer/hooks"; import { useTranslation } from "react-i18next"; @@ -9,16 +11,33 @@ import "./upload-background-image-button.scss"; export function UploadBackgroundImageButton() { const [isUploadingBackgroundImage, setIsUploadingBackgorundImage] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isMenuClosing, setIsMenuClosing] = useState(false); + const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false); + const buttonRef = useRef(null); + const menuRef = useRef(null); const { hasActiveSubscription } = useUserDetails(); const { t } = useTranslation("user_profile"); - const { isMe, setSelectedBackgroundImage } = useContext(userProfileContext); + const { isMe, setSelectedBackgroundImage, userProfile, getUserProfile } = + useContext(userProfileContext); const { patchUser, fetchUserDetails } = useUserDetails(); 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 { const { filePaths } = await window.electron.showOpenDialog({ properties: ["openFile"], @@ -40,23 +59,159 @@ export function UploadBackgroundImageButton() { showSuccessToast(t("background_image_updated")); await fetchUserDetails(); + await getUserProfile(); } } finally { 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; - return ( - + + ); + } + + // 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 && ( +
- - {isUploadingBackgroundImage ? t("uploading_banner") : t("upload_banner")} - + + +
+ ); + + return ( + <> +
+ +
+ {createPortal(menuContent, document.body)} + setShowRemoveBannerModal(false)} + onConfirm={handleRemoveBannerConfirm} + cancelButtonLabel={t("cancel")} + confirmButtonLabel={t("remove")} + buttonsIsDisabled={isUploadingBackgroundImage} + /> + ); } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5c28a27e..26be0632 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -3,7 +3,6 @@ export enum Downloader { Torrent, Gofile, PixelDrain, - Qiwi, Datanodes, Mediafire, TorBox, @@ -11,6 +10,7 @@ export enum Downloader { Buzzheavier, FuckingFast, VikingFile, + Rootz, } export enum DownloadSourceStatus { diff --git a/src/shared/index.ts b/src/shared/index.ts index 36996e1d..5da36bd9 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -110,7 +110,6 @@ export const getDownloadersForUri = (uri: string) => { if (uri.startsWith("https://gofile.io")) return [Downloader.Gofile]; 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://www.mediafire.com")) return [Downloader.Mediafire]; @@ -127,6 +126,9 @@ export const getDownloadersForUri = (uri: string) => { if (uri.startsWith("https://vikingfile.com")) { return [Downloader.VikingFile]; } + if (uri.startsWith("https://www.rootz.so")) { + return [Downloader.Rootz]; + } if (realDebridHosts.some((host) => uri.startsWith(host))) return [Downloader.RealDebrid];