From 1c1ee5df16cc6723ed5dca6f92e4c44ee2a5ed50 Mon Sep 17 00:00:00 2001 From: Verticool Date: Fri, 28 Feb 2025 21:49:36 +0600 Subject: [PATCH] chunk upload, modals, env update --- package-lock.json | 28 ++- src/env.d.ts | 1 + .../components/disclaimer-modal/index.tsx | 75 +++--- .../components/error-upload-modal/index.tsx | 75 ++++++ src/pages/root/steps/presubmit-step/index.tsx | 22 +- .../components/congrats-modal/index.tsx | 44 ++-- .../components/error-modal/index.tsx | 45 ++-- src/shared/services/file/index.ts | 223 +++++++++++++++--- yarn.lock | 5 + 9 files changed, 419 insertions(+), 99 deletions(-) create mode 100644 src/pages/root/steps/presubmit-step/components/error-upload-modal/index.tsx diff --git a/package-lock.json b/package-lock.json index 8b884c9..12fba59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.6.7", "buffer": "^6.0.3", "clsx": "^2.1.0", + "jssha": "^3.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.51.0", @@ -1437,6 +1438,26 @@ "jssha": "3.2.0" } }, + "node_modules/@ton/crypto-primitives/node_modules/jssha": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", + "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/@ton/crypto/node_modules/jssha": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", + "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "*" + } + }, "node_modules/@tonconnect/isomorphic-eventsource": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/@tonconnect/isomorphic-eventsource/-/isomorphic-eventsource-0.0.2.tgz", @@ -4202,11 +4223,10 @@ } }, "node_modules/jssha": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz", - "integrity": "sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", + "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": "*" } diff --git a/src/env.d.ts b/src/env.d.ts index 6c210c7..b252d65 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -3,6 +3,7 @@ interface ImportMetaEnv { readonly VITE_SENTRY_DSN: string readonly VITE_API_BASE_URL: string + readonly VITE_API_BASE_STORAGE_URL: string readonly MODE: 'development' | 'production' readonly PROD: boolean readonly DEV: boolean diff --git a/src/pages/root/steps/data-step/components/disclaimer-modal/index.tsx b/src/pages/root/steps/data-step/components/disclaimer-modal/index.tsx index 9b20d66..8c7cce5 100644 --- a/src/pages/root/steps/data-step/components/disclaimer-modal/index.tsx +++ b/src/pages/root/steps/data-step/components/disclaimer-modal/index.tsx @@ -1,4 +1,5 @@ -import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app"; +import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app"; +import { useEffect } from "react"; import { Button } from "~/shared/ui/button"; type DisclaimerModalProps = { @@ -9,6 +10,20 @@ export const DisclaimerModal = ({ onConfirm, }: DisclaimerModalProps) => { const [impactOccurred] = useHapticFeedback(); + const WebApp = useWebApp(); + useEffect(() => { + // Отключаем вертикальные свайпы при монтировании компонента + if (WebApp && WebApp.disableVerticalSwipes) { + WebApp.disableVerticalSwipes(); + } + + // Включаем вертикальные свайпы обратно при размонтировании + return () => { + if (WebApp && WebApp.enableVerticalSwipes) { + WebApp.enableVerticalSwipes(); + } + }; + }, []); const handleClick = (fn: () => void) => { impactOccurred("light"); @@ -21,40 +36,40 @@ export const DisclaimerModal = ({ "fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]" } > -
+
-

- «Внимание! -

-

- MY снимает с себя ответственность за правомерность загрузки контента пользователем. -

-

- - Сервис исходит из личной - - ответственности пользователя перед законом и третьими лицами. MY категорически не приемлет любые виды пиратства, но признает за Пользователем право принятия самостоятельных решений. -

-

- - Перед загрузкой контента - - необходимо убедиться, что первые 30 секунд контента, которые будут использоваться для превью, не содержат материалов, нарушающих возрастное ограничение 18+» -

-
- - +
); -}; +}; \ No newline at end of file diff --git a/src/pages/root/steps/presubmit-step/components/error-upload-modal/index.tsx b/src/pages/root/steps/presubmit-step/components/error-upload-modal/index.tsx new file mode 100644 index 0000000..2da2732 --- /dev/null +++ b/src/pages/root/steps/presubmit-step/components/error-upload-modal/index.tsx @@ -0,0 +1,75 @@ +import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app"; +import { useEffect } from "react"; +import { Button } from "~/shared/ui/button"; + +type ErrorUploadProps = { + onConfirm(): void; +}; + +export const ErrorUploadModal = ({ + onConfirm, +}: ErrorUploadProps) => { + const [impactOccurred] = useHapticFeedback(); + const WebApp = useWebApp(); + useEffect(() => { + // Отключаем вертикальные свайпы при монтировании компонента + if (WebApp && WebApp.disableVerticalSwipes) { + WebApp.disableVerticalSwipes(); + } + + // Включаем вертикальные свайпы обратно при размонтировании + return () => { + if (WebApp && WebApp.enableVerticalSwipes) { + WebApp.enableVerticalSwipes(); + } + }; + }, []); + + const handleClick = (fn: () => void) => { + impactOccurred("light"); + fn(); + }; + + return ( +
+
+
+
+

+ Внимание! +

+

+ Произошла ошибка при загрузке видео. +

+

+ + Загрузка не завершена + + из-за технических проблем или превышения допустимых ограничений. Вы можете попробовать загрузить файл ещё раз или выбрать другой файл. +

+

+ + Если проблема повторяется + + обратитесь в техническую поддержку сервиса MY, предоставив подробную информацию о загружаемом файле и возникшей ошибке. +

+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/pages/root/steps/presubmit-step/index.tsx b/src/pages/root/steps/presubmit-step/index.tsx index c9d986c..3c43b0b 100644 --- a/src/pages/root/steps/presubmit-step/index.tsx +++ b/src/pages/root/steps/presubmit-step/index.tsx @@ -2,7 +2,7 @@ import { useHapticFeedback, useWebApp, } from "@vkruglikov/react-telegram-web-app"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import ReactPlayer from "react-player/lazy"; import { Button } from "~/shared/ui/button"; @@ -13,6 +13,7 @@ import { Progress } from "~/shared/ui/progress"; import { useCreateNewContent } from "~/shared/services/content"; import { BackButton } from "~/shared/ui/back-button"; import { useTonConnectUI } from "@tonconnect/ui-react"; +import { ErrorUploadModal } from "./components/error-upload-modal"; @@ -35,6 +36,20 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => { const createContent = useCreateNewContent(); + const [isErrorUploadModal, setIsErrorUploadModal] = useState(false); + + const handleErrorUploadModal = () => { + setIsErrorUploadModal(false); + uploadFile.resetUploadError(); + uploadCover.resetUploadError(); + }; + + useEffect(() => { + if (uploadFile.uploadError || uploadCover.uploadError) { + setIsErrorUploadModal(true); + } + }, [uploadFile.uploadError, uploadCover.uploadError]); + const handleSubmit = async () => { try { let coverUploadResult = { content_id_v1: "" }; @@ -119,6 +134,11 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => { return (
+ {isErrorUploadModal && ( + { + handleErrorUploadModal()}} + />)}
diff --git a/src/pages/view-content/components/congrats-modal/index.tsx b/src/pages/view-content/components/congrats-modal/index.tsx index ee428a5..2f10901 100644 --- a/src/pages/view-content/components/congrats-modal/index.tsx +++ b/src/pages/view-content/components/congrats-modal/index.tsx @@ -1,4 +1,5 @@ -import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app"; +import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app"; +import { useEffect } from "react"; import { Button } from "~/shared/ui/button"; type CongratsModalProps = { @@ -9,25 +10,38 @@ export const CongratsModal = ({ onConfirm, }: CongratsModalProps) => { const [impactOccurred] = useHapticFeedback(); + const WebApp = useWebApp(); const handleClick = (fn: () => void) => { impactOccurred("light"); fn(); }; - + useEffect(() => { + // Отключаем вертикальные свайпы при монтировании компонента + if (WebApp && WebApp.disableVerticalSwipes) { + WebApp.disableVerticalSwipes(); + } + + // Включаем вертикальные свайпы обратно при размонтировании + return () => { + if (WebApp && WebApp.enableVerticalSwipes) { + WebApp.enableVerticalSwipes(); + } + }; + }, []); return (
-
-
-

+

+
+

🎉 Поздравляем с покупкой! 🎉 @@ -44,13 +58,13 @@ export const CongratsModal = ({

Спасибо, что выбираете MY!

-
+
); diff --git a/src/pages/view-content/components/error-modal/index.tsx b/src/pages/view-content/components/error-modal/index.tsx index 0837717..85f300d 100644 --- a/src/pages/view-content/components/error-modal/index.tsx +++ b/src/pages/view-content/components/error-modal/index.tsx @@ -1,4 +1,5 @@ -import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app"; +import { useHapticFeedback, useWebApp } from "@vkruglikov/react-telegram-web-app"; +import { useEffect } from "react"; import { Button } from "~/shared/ui/button"; type ErrorModalProps = { @@ -9,25 +10,39 @@ export const ErrorModal = ({ onConfirm, }: ErrorModalProps) => { const [impactOccurred] = useHapticFeedback(); + const WebApp = useWebApp(); const handleClick = (fn: () => void) => { impactOccurred("light"); fn(); }; + useEffect(() => { + // Отключаем вертикальные свайпы при монтировании компонента + if (WebApp && WebApp.disableVerticalSwipes) { + WebApp.disableVerticalSwipes(); + } + + // Включаем вертикальные свайпы обратно при размонтировании + return () => { + if (WebApp && WebApp.enableVerticalSwipes) { + WebApp.enableVerticalSwipes(); + } + }; + }, []); return ( +
+
-
-
-

+

Ошибка запроса транзакции

@@ -41,13 +56,13 @@ export const ErrorModal = ({

Если проблема не исчезает, убедитесь, что ваш кошелек работает корректно, или свяжитесь с поддержкой.

-
+
); diff --git a/src/shared/services/file/index.ts b/src/shared/services/file/index.ts index 03a830d..60bd57e 100644 --- a/src/shared/services/file/index.ts +++ b/src/shared/services/file/index.ts @@ -1,46 +1,201 @@ import { useMutation } from "react-query"; import { useState } from "react"; - import { request } from "~/shared/libs"; +const STORAGE_API_URL = import.meta.env.VITE_API_BASE_STORAGE_URL; +const MAX_CHUNK_SIZE = 80 * 1024 * 1024; // 80 MB + export const useUploadFile = () => { const [uploadProgress, setUploadProgress] = useState(0); const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); - const mutation = useMutation(["upload-file"], (file: File) => { + const mutation = useMutation(["upload-file"], async (file: File) => { + console.log(`Начинаем загрузку файла: ${file.name} (${file.size} байт)`); setIsUploading(true); + setUploadProgress(0); + setUploadError(null); // Сбрасываем ошибку перед началом новой загрузки - const formData = new FormData(); - formData.append("file", file); - - return request - .post<{ - content_sha256: string; - content_id_v1: string; - content_url: string; - }>("/storage", formData, { - headers: { - "Content-Type": "multipart/form-data", - Authorization: localStorage.getItem('auth_v1_token') || "" - }, - - onUploadProgress: (progressEvent) => { - const percentCompleted = Math.round( - (progressEvent.loaded * 100) / (progressEvent?.total as number) || - 0, - ); - setUploadProgress(percentCompleted); - }, - }) - .then((response) => { - setIsUploading(false); - return response.data; - }) - .catch((error) => { - setIsUploading(false); - throw error; - }); + try { + // Для маленьких файлов используем обычную загрузку, но с теми же заголовками + if (file.size <= MAX_CHUNK_SIZE) { + console.log("Используем обычную загрузку (файл <= MAX_CHUNK_SIZE)"); + + // Подготавливаем заголовки - такие же, как для чанковой загрузки + const headers: Record = { + "X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64 + "X-Chunk-Start": "0", // Начинаем с позиции 0 + "Content-Type": file.type || "application/octet-stream", + "X-Last-Chunk": "1" // Это единственный и последний чанк + }; + + // Добавляем заголовок авторизации + const authToken = localStorage.getItem('auth_v1_token'); + if (authToken) { + headers["Authorization"] = authToken; + } + + console.log("Заголовки запроса:", headers); + + try { + const response = await request.post<{ + upload_id?: string; + content_sha256?: string; + content_id?: string; + content_id_v1?: string; + content_url?: string; + }>("", file, { // Отправляем файл напрямую вместо FormData + baseURL: STORAGE_API_URL, + headers, + onUploadProgress: (progressEvent) => { + const percentCompleted = Math.round( + (progressEvent.loaded * 100) / (progressEvent?.total as number || file.size) + ); + setUploadProgress(Math.min(99, percentCompleted)); + }, + }); + + console.log("Ответ на обычную загрузку:", response.data); + setUploadProgress(100); + + return { + content_sha256: response.data.content_sha256 || "", + content_id_v1: response.data.content_id_v1 || response.data.content_id || "", + content_url: response.data.content_url || "" + }; + } catch (error) { + console.error("Ошибка при обычной загрузке:", error); + setUploadError(error instanceof Error ? error : new Error('Ошибка при загрузке файла')); + throw error; + } + } + + // Для больших файлов используем чанковую загрузку + console.log("Используем чанкированную загрузку (файл > MAX_CHUNK_SIZE)"); + + let offset = 0; + let uploadId: string | null = null; + let chunkNumber = 0; + + // Загружаем файл по чанкам + while (offset < file.size) { + chunkNumber++; + const chunkEnd = Math.min(offset + MAX_CHUNK_SIZE, file.size); + const chunk = file.slice(offset, chunkEnd); + console.log(`Загрузка чанка #${chunkNumber} начиная с байта ${offset}`); + + // Определяем, является ли текущий чанк последним + const isLastChunk = chunkEnd === file.size; + + // Подготавливаем заголовки + const headers: Record = { + "X-File-Name": btoa(unescape(encodeURIComponent(file.name))), // Имя файла в base64 + "X-Chunk-Start": offset.toString(), + "Content-Type": file.type || "application/octet-stream" + }; + + // Добавляем маркер последнего чанка, если это последний чанк + if (isLastChunk) { + headers["X-Last-Chunk"] = "1"; + } + + // Добавляем заголовок авторизации + const authToken = localStorage.getItem('auth_v1_token'); + if (authToken) { + headers["Authorization"] = authToken; + } + + // Если есть uploadId, добавляем его в заголовки + if (uploadId) { + headers["X-Upload-ID"] = uploadId; + } + + console.log("Заголовки запроса:", headers); + + try { + const response = await request.post<{ + upload_id?: string; + current_size?: number; + content_id?: string; + content_sha256?: string; + content_id_v1?: string; + content_url?: string; + }>("", chunk, { + baseURL: STORAGE_API_URL, + headers, + onUploadProgress: (progressEvent) => { + // Прогресс загрузки текущего чанка + const overallProgress = offset + progressEvent.loaded; + const percentCompleted = Math.round((overallProgress / file.size) * 100); + setUploadProgress(Math.min(99, percentCompleted)); + }, + }); + + console.log(`Ответ на чанк #${chunkNumber}:`, response.data); + + // Сохраняем uploadId из первого ответа, если не установлен + if (!uploadId && response.data.upload_id) { + uploadId = response.data.upload_id; + console.log("Получен upload_id:", uploadId); + } + + // Проверка на наличие content_id + if (response.data.content_id) { + console.log("Загрузка завершена. ID файла:", response.data.content_id); + setUploadProgress(100); + + return { + content_sha256: response.data.content_sha256 || "", + content_id_v1: response.data.content_id_v1 || response.data.content_id || "", + content_url: response.data.content_url || "" + }; + } + + // Обновляем смещение на основе ответа сервера + if (response.data.current_size !== undefined) { + offset = response.data.current_size; + console.log(`Сервер сообщает current_size: ${offset}`); + } else { + console.warn("Неожиданный ответ от сервера, отсутствует current_size"); + const error = new Error("Missing current_size in response"); + setUploadError(error); + throw error; + } + } catch (error: any) { + console.error(`Ошибка при загрузке чанка #${chunkNumber}:`, error); + if (error.response) { + console.error("Ответ сервера:", error.response.status, error.response.data); + } + setUploadError(error instanceof Error ? error : new Error(`Ошибка при загрузке чанка #${chunkNumber}`)); + throw error; + } + } + + const error = new Error("Ошибка загрузки файла: все чанки загружены, но content_id не получен"); + console.error(error.message); + setUploadError(error); + throw error; + } catch (error: any) { + console.error("Ошибка при загрузке:", error); + if (error.response) { + console.error("Ответ сервера:", error.response.status, error.response.data); + } + setUploadError(error instanceof Error ? error : new Error("Неизвестная ошибка при загрузке")); + setIsUploading(false); + throw error; + } finally { + setIsUploading(false); + } }); - return { ...mutation, uploadProgress, isUploading }; -}; + // Сбросить ошибку + const resetUploadError = () => setUploadError(null); + + return { + ...mutation, + uploadProgress, + isUploading, + uploadError, + resetUploadError + }; +}; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9511a7a..ea8fb39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2076,6 +2076,11 @@ json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jssha@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz" + integrity sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ== + jssha@3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/jssha/-/jssha-3.2.0.tgz"