Compare commits

...

1 Commits

Author SHA1 Message Date
root d2e350bc28 smashed content view fixes 2025-10-16 16:22:15 +00:00
6 changed files with 227 additions and 137 deletions

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@ -17,12 +17,29 @@ import { HashtagInput } from "~/shared/ui/hashtag-input";
import { Replace } from "~/shared/ui/icons/replace"; import { Replace } from "~/shared/ui/icons/replace";
import { DisclaimerModal } from "./components/disclaimer-modal"; import { DisclaimerModal } from "./components/disclaimer-modal";
const formatFileSize = (bytes: number | undefined) => {
if (!bytes) {
return "";
}
const units = ["Б", "КБ", "МБ", "ГБ"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
};
type DataStepProps = { type DataStepProps = {
nextStep(): void; nextStep(): void;
}; };
export const DataStep = ({ nextStep }: DataStepProps) => { export const DataStep = ({ nextStep }: DataStepProps) => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const isAudioFile = rootStore.fileType?.startsWith("audio");
const isVideoFile = rootStore.fileType?.startsWith("video");
const isMediaFile = isAudioFile || isVideoFile;
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false); const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
@ -61,11 +78,13 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
})(); })();
}; };
const handleFileReset = () => { const handleFileReset = useCallback(() => {
rootStore.setFile(null); rootStore.setFile(null);
rootStore.setFileSrc(''); rootStore.setFileSrc('');
rootStore.setFileType(''); rootStore.setFileType('');
} rootStore.setDownloadLocked(false);
rootStore.setAllowDwnld(false);
}, [rootStore]);
useEffect(() => { useEffect(() => {
@ -80,6 +99,39 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
localStorage.setItem('disclaimerAccepted', 'true'); localStorage.setItem('disclaimerAccepted', 'true');
}; };
useEffect(() => {
if (!rootStore.file) {
if (rootStore.isDownloadLocked) {
rootStore.setDownloadLocked(false);
}
return;
}
if (!isMediaFile) {
if (!rootStore.allowDwnld) {
rootStore.setAllowDwnld(true);
}
if (!rootStore.isDownloadLocked) {
rootStore.setDownloadLocked(true);
}
} else if (rootStore.isDownloadLocked) {
rootStore.setDownloadLocked(false);
}
}, [
isMediaFile,
rootStore,
rootStore.allowDwnld,
rootStore.file,
rootStore.isDownloadLocked,
]);
const handleFileChange = useCallback((file: File) => {
rootStore.setFile(file);
rootStore.setFileSrc(URL.createObjectURL(file));
const mime = file.type || "application/octet-stream";
rootStore.setFileType(mime);
}, [rootStore]);
return ( return (
<section className={"mt-4 px-4 pb-8"}> <section className={"mt-4 px-4 pb-8"}>
@ -118,20 +170,18 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
</FormLabel> </FormLabel>
<FormLabel label={"Файл"}> <FormLabel label={"Файл"}>
{!rootStore.fileSrc && <> {!rootStore.fileSrc && (
<>
<HiddenFileInput <HiddenFileInput
id={"file"} id={"file"}
shouldProcess={false} shouldProcess={false}
accept={"video/mp4,video/x-m4v,video/*,audio/mp3,audio/*"} accept={"*/*"}
onChange={(file) => { onChange={handleFileChange}
rootStore.setFile(file);
rootStore.setFileSrc(URL.createObjectURL(file));
rootStore.setFileType(file.type); // Save the file type for conditional rendering
}}
/> />
<FileButton htmlFor={"file"} /> <FileButton htmlFor={"file"} />
</>} </>
)}
{rootStore.fileSrc && ( {rootStore.fileSrc && (
<div <div
@ -139,7 +189,8 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm" "w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm"
} }
> >
{rootStore.fileType?.startsWith("audio") ? ( {isMediaFile ? (
isAudioFile ? (
<AudioPlayer src={rootStore.fileSrc} /> <AudioPlayer src={rootStore.fileSrc} />
) : ( ) : (
<ReactPlayer <ReactPlayer
@ -149,6 +200,16 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
config={{ file: { attributes: { playsInline: true } } }} config={{ file: { attributes: { playsInline: true } } }}
url={rootStore.fileSrc} url={rootStore.fileSrc}
/> />
)
) : (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="font-semibold">{rootStore.file?.name}</span>
<span className="text-xs text-[#7B7B7B]">
{formatFileSize(rootStore.file?.size)}
</span>
</div>
</div>
)} )}
<button <button
onClick={handleFileReset} onClick={handleFileReset}
@ -172,9 +233,15 @@ export const DataStep = ({ nextStep }: DataStepProps) => {
<Checkbox <Checkbox
onClick={() => rootStore.setAllowDwnld(!rootStore.allowDwnld)} onClick={() => rootStore.setAllowDwnld(!rootStore.allowDwnld)}
checked={rootStore.allowDwnld} checked={rootStore.allowDwnld}
disabled={rootStore.isDownloadLocked}
/> />
} }
/> />
{rootStore.isDownloadLocked && (
<p className="text-xs text-[#7B7B7B]">
Скачивание всегда доступно для документов и других не медиа-файлов.
</p>
)}
<FormLabel <FormLabel
label={"Разрешить обложку"} label={"Разрешить обложку"}
labelClassName={"flex"} labelClassName={"flex"}

View File

@ -14,6 +14,7 @@ import { useCreateNewContent } from "~/shared/services/content";
import { BackButton } from "~/shared/ui/back-button"; import { BackButton } from "~/shared/ui/back-button";
import { useTonConnectUI } from "@tonconnect/ui-react"; import { useTonConnectUI } from "@tonconnect/ui-react";
import { ErrorUploadModal } from "./components/error-upload-modal"; import { ErrorUploadModal } from "./components/error-upload-modal";
import { AudioPlayer } from "~/shared/ui/audio-player";
@ -37,6 +38,9 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
const createContent = useCreateNewContent(); const createContent = useCreateNewContent();
const [isErrorUploadModal, setIsErrorUploadModal] = useState(false); const [isErrorUploadModal, setIsErrorUploadModal] = useState(false);
const isAudioFile = rootStore.fileType?.startsWith("audio");
const isVideoFile = rootStore.fileType?.startsWith("video");
const isMediaFile = isAudioFile || isVideoFile;
const handleErrorUploadModal = () => { const handleErrorUploadModal = () => {
setIsErrorUploadModal(false); setIsErrorUploadModal(false);
@ -290,6 +294,10 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
"w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm" "w-full border border-white bg-[#2B2B2B] px-[10px] py-[8px] text-sm"
} }
> >
{isMediaFile ? (
isAudioFile ? (
<AudioPlayer src={rootStore.fileSrc} />
) : (
<ReactPlayer <ReactPlayer
playsinline={true} playsinline={true}
controls={true} controls={true}
@ -297,9 +305,19 @@ export const PresubmitStep = ({ prevStep }: PresubmitStepProps) => {
config={{ file: { attributes: { playsInline: true } } }} config={{ file: { attributes: { playsInline: true } } }}
url={rootStore.fileSrc} url={rootStore.fileSrc}
/> />
)
) : (
<div className="flex flex-col gap-1">
<span className="font-semibold">{rootStore.file?.name}</span>
<span className="text-xs text-[#7B7B7B]">
{rootStore.file?.size
? `${(rootStore.file.size / (1024 * 1024)).toFixed(1)} МБ`
: ""}
</span>
</div>
)}
</div> </div>
)} )}
{uploadFile.isUploading && uploadFile.isLoading && ( {uploadFile.isUploading && uploadFile.isLoading && (
<Progress value={uploadFile.uploadProgress} /> <Progress value={uploadFile.uploadProgress} />
)} )}

View File

@ -36,37 +36,21 @@ export const ViewContentPage = () => {
const [isCongratsModal, setIsCongratsModal] = useState(false); const [isCongratsModal, setIsCongratsModal] = useState(false);
const [isErrorModal, setIsErrorModal] = useState(false); const [isErrorModal, setIsErrorModal] = useState(false);
const statusState = content?.data?.status?.state ?? "uploaded"; const displayOptions = content?.data?.display_options ?? {};
const conversionState = content?.data?.conversion?.state; const mediaUrl = displayOptions?.content_url ?? null;
const uploadState = content?.data?.upload?.state; const downloadUrl = displayOptions?.download_url ?? null;
const statusMessage = useMemo(() => { const metadata = displayOptions?.metadata ?? {};
switch (statusState) { const metadataTitle = metadata?.title || metadata?.name || content?.data?.encrypted?.title || 'Контент';
case "processing": const metadataArtist = metadata?.artist || content?.data?.encrypted?.artist || null;
return "Контент обрабатывается"; const displayTitle = metadata?.display_name || (metadataArtist ? `${metadataArtist} ${metadataTitle}` : metadataTitle);
case "failed": const contentKind =
return "Ошибка обработки"; displayOptions?.content_kind ??
case "ready": content?.data?.content_kind ??
return null; ((content?.data?.content_type ?? '').split('/')[0] || 'other');
default: const isAudio = contentKind === 'audio';
return "Файл загружен"; const hasMediaPlayer = Boolean(mediaUrl);
} const coverUrl = metadata?.image ?? null;
}, [statusState]); const canDownload = Boolean(content?.data?.downloadable && downloadUrl);
const mediaUrl = content?.data?.display_options?.content_url ?? null;
const isAudio = Boolean(content?.data?.content_type?.startsWith('audio'));
const isReady = Boolean(mediaUrl);
const metadataName = content?.data?.display_options?.metadata?.name;
const contentTitle = metadataName || content?.data?.encrypted?.title || 'Контент';
const processingDetails = useMemo(() => {
if (!statusMessage) {
return null;
}
return {
conversion: conversionState,
upload: uploadState,
};
}, [conversionState, statusMessage, uploadState]);
const handleBuyContentTON = useCallback(async () => { const handleBuyContentTON = useCallback(async () => {
if (!contentId) { if (!contentId) {
console.error('No content identifier available for purchase'); console.error('No content identifier available for purchase');
@ -202,10 +186,10 @@ export const ViewContentPage = () => {
}, [haveLicense, refetchContent]); }, [haveLicense, refetchContent]);
useEffect(() => { useEffect(() => {
if (contentTitle) { if (displayTitle) {
document.title = contentTitle; document.title = displayTitle;
} }
}, [contentTitle]); }, [displayTitle]);
useEffect(() => { useEffect(() => {
if (!contentId) { if (!contentId) {
@ -229,12 +213,17 @@ export const ViewContentPage = () => {
const handleDwnldContent = async () => { const handleDwnldContent = async () => {
try { try {
const fileUrl = content?.data?.display_options?.content_url; const fileUrl = downloadUrl ?? mediaUrl;
const fileName = content?.data?.display_options?.metadata?.name || 'content'; if (!fileUrl) {
const fileFormat = content?.data?.content_ext || '.raw'; console.error('No file URL available for download');
return;
}
const fileName = metadataTitle || 'content';
const rawExtension = content?.data?.content_ext || metadata?.file_extension || 'raw';
const normalizedExtension = rawExtension.startsWith('.') ? rawExtension.slice(1) : rawExtension;
await WebApp.downloadFile({ await WebApp.downloadFile({
url: fileUrl, url: fileUrl,
file_name: fileName + '.' + fileFormat, file_name: `${fileName}.${normalizedExtension || 'raw'}`,
}); });
} catch (error) { } catch (error) {
console.error('Error downloading content:', error); console.error('Error downloading content:', error);
@ -245,19 +234,19 @@ export const ViewContentPage = () => {
<main className={'min-h-screen flex w-full flex-col gap-[50px] px-4'}> <main className={'min-h-screen flex w-full flex-col gap-[50px] px-4'}>
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />} {isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />} {isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
{isReady && isAudio &&
content?.data?.display_options?.metadata?.image && ( {hasMediaPlayer ? (
<>
{isAudio && coverUrl && (
<div className={'mt-[30px] h-[314px] w-full'}> <div className={'mt-[30px] h-[314px] w-full'}>
<img <img
alt={'content_image'} alt={'content_image'}
className={'h-full w-full object-cover object-center'} className={'h-full w-full object-cover object-center'}
src={content?.data?.display_options?.metadata?.image} src={coverUrl}
/> />
</div> </div>
)} )}
{isReady ? (
<>
{isAudio ? ( {isAudio ? (
<AudioPlayer src={mediaUrl ?? ''} /> <AudioPlayer src={mediaUrl ?? ''} />
) : ( ) : (
@ -270,7 +259,7 @@ export const ViewContentPage = () => {
attributes: { attributes: {
playsInline: true, playsInline: true,
autoPlay: true, autoPlay: true,
poster: content?.data?.display_options?.metadata?.image || undefined, poster: coverUrl || undefined,
}, },
}, },
}} }}
@ -279,17 +268,51 @@ export const ViewContentPage = () => {
)} )}
<section className={'flex flex-col'}> <section className={'flex flex-col'}>
<h1 className={'text-[20px] font-bold'}>{metadataName}</h1> <h1 className={'text-[20px] font-bold'}>{displayTitle}</h1>
{metadata?.description && (
<p className={'mt-2 text-[12px]'}> <p className={'mt-2 text-[12px]'}>
{content?.data?.display_options?.metadata?.description} {metadata.description}
</p> </p>
)}
</section> </section>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center py-16">
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-8 text-center shadow-lg shadow-black/30">
{coverUrl && (
<div className="mx-auto mb-4 h-32 w-32 overflow-hidden rounded-xl">
<img
alt="content_cover"
className="h-full w-full object-cover object-center"
src={coverUrl}
/>
</div>
)}
<h1 className="text-lg font-semibold text-slate-100">
Контент скоро будет доступен
</h1>
<p className="mt-2 text-sm font-semibold text-slate-100">
{displayTitle}
</p>
{metadata?.description && (
<p className="mt-3 text-sm text-slate-300">
{metadata.description}
</p>
)}
{canDownload && (
<p className="mt-4 text-xs text-slate-400">
Файл можно скачать ниже.
</p>
)}
</div>
</div>
)}
<div className="mt-auto pb-2"> <div className="mt-auto pb-2">
{content?.data?.downloadable && ( {canDownload && (
<Button <Button
onClick={() => handleDwnldContent()} onClick={handleDwnldContent}
className={'h-[48px] mb-4'} className={'mb-4 h-[48px]'}
label={`Скачать контент`} label={`Скачать контент`}
/> />
)} )}
@ -322,39 +345,11 @@ export const ViewContentPage = () => {
onClick={() => { onClick={() => {
tonConnectUI.disconnect(); tonConnectUI.disconnect();
}} }}
className={'h-[48px] bg-darkred mt-4'} className={'mt-4 h-[48px] bg-darkred'}
label={`Отключить кошелек`} label={`Отключить кошелек`}
/> />
)} )}
</div> </div>
</>
) : (
<div className="flex flex-1 flex-col items-center justify-center py-16">
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-8 text-center shadow-lg shadow-black/30">
<h1 className="text-lg font-semibold text-slate-100">
Контент скоро будет здесь
</h1>
<p className="mt-3 text-sm text-slate-300">
Мы уже обрабатываем загруженный файл и обновим страницу автоматически, как только появится доступ к полному контенту.
</p>
{statusMessage && (
<p className="mt-4 text-[12px] text-slate-500">
Текущее состояние: {statusMessage}
</p>
)}
{processingDetails?.conversion && (
<p className="mt-2 text-[12px] text-slate-500">
Статус конвертера: {processingDetails.conversion}
</p>
)}
{processingDetails?.upload && (
<p className="mt-2 text-[12px] text-slate-500">
Загрузка: {processingDetails.upload}
</p>
)}
</div>
</div>
)}
</main> </main>
); );
}; };

View File

@ -14,6 +14,7 @@ const VIEW_CONTENT_API_PATH = "/api/v1";
type UseCreateNewContentPayload = { type UseCreateNewContentPayload = {
title: string; title: string;
artist?: string;
content: string; content: string;
image: string; image: string;
description: string; description: string;
@ -39,6 +40,7 @@ export const useCreateNewContent = () => {
royaltyCount: payload.royaltyParams.length, royaltyCount: payload.royaltyParams.length,
downloadable: payload.downloadable, downloadable: payload.downloadable,
allowResale: payload.allowResale, allowResale: payload.allowResale,
artist: payload.artist,
}); });
try { try {

View File

@ -35,6 +35,8 @@ type RootStore = {
allowDwnld: boolean; allowDwnld: boolean;
setAllowDwnld: (allowDwnld: boolean) => void; setAllowDwnld: (allowDwnld: boolean) => void;
isDownloadLocked: boolean;
setDownloadLocked: (locked: boolean) => void;
cover: File | null; cover: File | null;
setCover: (cover: File | null) => void; setCover: (cover: File | null) => void;
@ -86,6 +88,8 @@ export const useRootStore = create<RootStore>((set) => ({
allowDwnld: false, allowDwnld: false,
setAllowDwnld: (allowDwnld) => set({ allowDwnld }), setAllowDwnld: (allowDwnld) => set({ allowDwnld }),
isDownloadLocked: false,
setDownloadLocked: (isDownloadLocked) => set({ isDownloadLocked }),
cover: null, cover: null,
setCover: (cover) => set({ cover }), setCover: (cover) => set({ cover }),

View File

@ -3,12 +3,16 @@ import { useHapticFeedback } from "@vkruglikov/react-telegram-web-app";
type CheckboxProps = { type CheckboxProps = {
onClick(): void; onClick(): void;
checked: boolean; checked: boolean;
disabled?: boolean;
}; };
export const Checkbox = ({ onClick, checked }: CheckboxProps) => { export const Checkbox = ({ onClick, checked, disabled }: CheckboxProps) => {
const [impactOccurred] = useHapticFeedback(); const [impactOccurred] = useHapticFeedback();
const handleClick = () => { const handleClick = () => {
if (disabled) {
return;
}
impactOccurred("light"); impactOccurred("light");
onClick(); onClick();
}; };
@ -16,7 +20,7 @@ export const Checkbox = ({ onClick, checked }: CheckboxProps) => {
return ( return (
<div <div
onClick={handleClick} onClick={handleClick}
className={"flex h-8 w-8 items-center justify-center bg-[#2B2B2B] p-2"} className={"flex h-8 w-8 items-center justify-center bg-[#2B2B2B] p-2" + (disabled ? " opacity-40" : "")}
> >
{checked && <div className={"h-full w-full bg-primary"} />} {checked && <div className={"h-full w-full bg-primary"} />}
</div> </div>