chunk upload, modals, env update

This commit is contained in:
Verticool committed 2025-02-28 21:49:36 +06:00
1 parent 7cb7a7d73a
commit 1c1ee5df16
9 files changed
+419 -99

No files matched your search

+1
View File
@@ -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
@@ -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]"
}
>
<div className={"flex flex-col gap-[30px]"}>
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-start flex flex-col gap-12"
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-12 h-full overflow-y-auto"
}
>
<p className="mt-4">
«Внимание!
</p>
<p className="">
MY снимает с себя ответственность за правомерность загрузки контента пользователем.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Сервис исходит из личной
</span>
ответственности пользователя перед законом и третьими лицами. MY категорически не приемлет любые виды пиратства, но признает за Пользователем право принятия самостоятельных решений.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Перед загрузкой контента
</span>
необходимо убедиться, что первые 30 секунд контента, которые будут использоваться для превью, не содержат материалов, нарушающих возрастное ограничение 18+»
</p>
<Button
className={"mt-[20px]"}
label={"Принять и продолжить"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
<div className="flex flex-col gap-6">
<p className="mt-2">
Внимание!
</p>
<p>
MY снимает с себя ответственность за правомерность загрузки контента пользователем.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Сервис исходит из личной
</span>
ответственности пользователя перед законом и третьими лицами. MY категорически не приемлет любые виды пиратства, но признает за Пользователем право принятия самостоятельных решений.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Перед загрузкой контента
</span>
необходимо убедиться, что первые 30 секунд контента, которые будут использоваться для превью, не содержат материалов, нарушающих возрастное ограничение 18+
</p>
</div>
</div>
<Button
className={"mt-[20px] sticky bottom-0"}
label={"Принять и продолжить"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
</div>
);
};
};
@@ -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 (
<div
className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
}
>
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-12 h-full overflow-y-auto"
}
>
<div className="flex flex-col gap-6">
<p className="mt-2">
Внимание!
</p>
<p>
Произошла ошибка при загрузке видео.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Загрузка не завершена
</span>
из-за технических проблем или превышения допустимых ограничений. Вы можете попробовать загрузить файл ещё раз или выбрать другой файл.
</p>
<p className="flex flex-col">
<span className="tracking-[.25em] w-full">
Если проблема повторяется
</span>
обратитесь в техническую поддержку сервиса MY, предоставив подробную информацию о загружаемом файле и возникшей ошибке.
</p>
</div>
</div>
<Button
className={"mt-[20px] sticky bottom-0"}
label={"Понятно"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
</div>
);
};
+21 -1
View File
@@ -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 (
<section className={"mt-4 px-4 pb-8"}>
{isErrorUploadModal && (
<ErrorUploadModal
onConfirm={() => {
handleErrorUploadModal()}}
/>)}
<BackButton onClick={prevStep} />
<div className={"mb-[30px] flex flex-col text-sm"}>
@@ -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 (
<div
className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
}
>
<div className={"flex flex-col gap-[30px]"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-start flex flex-col gap-12"
}
>
<p className="mt-12">
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-6 h-full overflow-y-auto"
}
>
<p className="mt-4">
<span className="text-xl">🎉</span>
<span className="px-1 font-bold">Поздравляем с покупкой!</span>
<span className="text-xl">🎉</span>
@@ -44,13 +58,13 @@ export const CongratsModal = ({
<p className="flex flex-col">
Спасибо, что выбираете MY!
</p>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
</div>
);
@@ -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 (
<div
className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
}
>
<div className={"flex flex-col max-h-[80vh] w-[85vw] max-w-md"}>
<div
className={
"fixed left-0 top-0 z-30 flex h-full w-full items-center justify-center bg-black/80 px-[15px]"
"border border-white bg-[#1D1D1B] px-[15px] py-[16px] text-start flex flex-col gap-6 h-full overflow-y-auto"
}
>
<div className={"flex flex-col gap-[30px]"}>
<div
className={
"border border-white bg-[#1D1D1B] px-[10px] py-[16px] text-start flex flex-col gap-12"
}
>
<p className="mt-12">
<p className="mt-4">
<span className="font-bold">Ошибка запроса транзакции</span>
</p>
<p className="">
@@ -41,13 +56,13 @@ export const ErrorModal = ({
<p className="flex flex-col">
Если проблема не исчезает, убедитесь, что ваш кошелек работает корректно, или свяжитесь с поддержкой.
</p>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
<Button
className={"mt-[20px]"}
label={"Ок"}
includeArrows={false}
onClick={() => handleClick(onConfirm)}
/>
</div>
</div>
);
+189 -34
View File
@@ -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<Error | null>(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<string, string> = {
"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<string, string> = {
"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
};
};