@@ -6,7 +6,7 @@ import { API_URL } from '#/shared/lib/constants' -export async function sendMediaMessage(model: string | null, modelType: 'video' | 'image', dataForSend: MessageSend | FormData, token?: string) { +export async function sendMediaMessage(model: string | null, modelType: 'video' | 'image' | 'audio', dataForSend: MessageSend | FormData, token?: string) { const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' return await axios.post(API_URL + `/media/${modelType}/${model}`, dataForSend, { @@ -19,7 +19,7 @@ export async function sendMediaMessage(model: string | null, modelType: 'vide }) } -export async function getImagesBySlug(slug: string, token: string, type:'image' | 'video', offset?: number, limit = 10) { +export async function getImagesBySlug(slug: string, token: string, type:'image' | 'video' | 'audio', offset?: number, limit = 10) { return await axios.get(API_URL + `/media/${type}/${slug}?limit=${limit}&offset=${offset}`, { validateStatus: (status) => status < 500, headers: { @@ -0,0 +1,71 @@ +import React, { useMemo } from 'react' +import Image from 'next/image' +import Link from 'next/link' + +import styles from './card.module.scss' + +import BlockedSvg from '#/assets/svg/blocked.svg?react' +import LockSvg from '#/assets/svg/lock.svg?react' +import { IShortModel } from '#/entities/model-entity' +import { c } from '#/shared/lib/helpers' +import { useThemeAndDevice } from '#/shared/lib/hooks' +import { CommonButton } from '#/shared/ui/button' +import { SvgIcon } from '#/shared/ui/svg' + +export interface AudioModelCardProps extends IShortModel { + accessed_models: string[] | null +} + +export function AudioModelCard({ description, image, title, slug, accessed_models, blocked, tags }: AudioModelCardProps) { + const link = useMemo(() => { + return accessed_models && !accessed_models.includes(slug) ? '/account?scope=subscribe' : `audio/${slug}` + }, [accessed_models]) + + const { theme } = useThemeAndDevice() + + return ( + +
+
+ {title} +
+
+
+

{title}

+

{description}

+
+ + {blocked && ( +
+ + Модель недоступна +
+ )} + {accessed_models && !accessed_models.includes(slug) && ( +
+
+ + Недоступно в текущем тарифе +
+ + Выбрать тариф + +
+ )} +
+ +
+ {tags && + tags.map((tag, index) => ( +
+ + {tag.title} +
+ ))} +
+
+ + ) +} + + @@ -140,7 +140,7 @@ .locked { display: flex; align-items: center; - justify-content: end; + justify-content: flex-end; flex-direction: column; position: absolute; top: 0; @@ -138,7 +138,7 @@ .locked { display: flex; align-items: center; - justify-content: end; + justify-content: flex-end; flex-direction: column; position: absolute; top: 0; @@ -28,7 +28,7 @@ export function ImageModelCard({ description, image, title, slug, accessed_model
- {title} + {title}
@@ -1,3 +1,4 @@ export * from './chat-model-card' export * from './image-model-card' -export * from './video-model-card' \ No newline at end of file +export * from './video-model-card' +export * from './audio-model-card' \ No newline at end of file @@ -28,7 +28,7 @@ export function VideoModelCard({ description, image, title, slug, accessed_model
- {title} + {title}
@@ -10,7 +10,7 @@ import { formDataHelper } from '#/widgets/messages' export function useCreateMediaMessage( showError: (message: string) => void, type: string, - modelType: 'video' | 'image', + modelType: 'video' | 'image' | 'audio', device: Device, setMessages: Dispatch>, mobileScrollContainer: RefObject @@ -9,7 +9,7 @@ import { getImagesBySlug, Message } from '#/entities/message' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { Device } from '#/shared/lib/types/entities' -export function useMediaBotPagination(deviceType: Device, type:'image' | 'video') { +export function useMediaBotPagination(deviceType: Device, type:'image' | 'video' | 'audio') { const refScrollMobile = useRef(null) const refScrollDesktop = useRef(null) const mobileScrollContainer = useRef(null) @@ -0,0 +1,8 @@ +import { AudioModelPage } from '#/views/audio' +import { getDefaultLayout } from '#/widgets/layouts' + +AudioModelPage.getLayout = getDefaultLayout() + +export default AudioModelPage + + @@ -134,6 +134,7 @@ export const api = { Authorization: token ? `Bearer ${token}` : '', }, }) + return data } catch (err) { @@ -0,0 +1,134 @@ +.container { + width: 100%; + border-radius: 15px; + height: 100%; + position: absolute; + bottom: 0; + left: 0; + background-color: var(--new-ui-main-color); + opacity: 0; + z-index: -1; + transition: all 0.3s ease-in-out; + + &_active { + opacity: 1; + z-index: 100; + } +} + +.header { + width: 70%; + margin-bottom: 50px; + + @media screen and (max-width: 768px) { + width: 100%; + margin-bottom: 0px; + } +} + +@keyframes fadein { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.icon { +} + +.tags { + min-width: 100%; + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + @media screen and (max-width: 768px) { + justify-content: flex-start; + } +} + +.tag { + display: flex; + align-items: center; + justify-content: center; + background-color: white; + border-radius: 100px; + width: 50px; + height: 50px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.1); + transition: width 0.3s ease-in-out; + + @media screen and (max-width: 768px) { + height: 40px; + width: 40px; + } + + &__icon { + color: var(--air-color); + } + + &:hover { + padding: 12px; + width: 200px; + + .tag__icon { + animation: fadein 0.6s ease-in-out; + } + + .tag__text { + opacity: 1; + position: static; + animation: fadein 0.6s ease-in-out; + display: block; + } + } + + &__text { + position: absolute; + color: var(--air-color); + font-weight: 500; + padding-left: 10px; + font-size: 14px; + opacity: 0; + display: none; + min-width: max-content !important; + } +} + +.blocked { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: #c32528; + padding: 20px; + gap: 10px; + position: absolute; + + left: 0; + + width: 100%; + border-radius: 15px; + top: 0; + + @media screen and (max-width: 768px) { + top: unset; + // bottom: -22px; + z-index: 1; + border-radius: 15px; + } + + &__text { + color: white !important; + font-size: 16px; + font-weight: 600; + } +} + + @@ -0,0 +1,444 @@ +import { useEffect, useMemo, useState } from 'react' +import { Box, Collapse, Stack, Typography } from '@mui/material' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { useSession } from 'next-auth/react' + +import styles from './audio-model.module.scss' + +import { ChatSelect } from '#/app/components/chat_select' +import { ResetFilters } from '#/app/components/filters/reset_filters' +import BlockedSvg from '#/assets/svg/blocked.svg?react' +import { useImageBot } from '#/entities/model-entity/model/use-image-bot' +import BotParamsMap from '#/features/bot-params/bot-params-map' +import { useCreateMediaMessage } from '#/features/create-media-message' +import { useImagesBotFilters } from '#/features/image-bot-filters' +import { useImagesUniqInput } from '#/features/image-bot-input' +import { useMediaBotPagination } from '#/features/image-bot-pagination' +import { ModelInput } from '#/features/model-input' +import Title from '#/features/title/title' +import { NextPageWithLayout } from '#/pages/_app' +import { DrawerCustom, Loader } from '#/shared' +import { c, getDeviceType, getOs } from '#/shared/lib/helpers' +import { useThemeAndDevice } from '#/shared/lib/hooks' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { SvgIcon } from '#/shared/ui/svg' +import { AudioMessagesList } from '#/widgets/messages/ui/audio-messages-list' +import { ModelApiView, StaticTabs } from '#/widgets/model-api-view' + +const AudioModelPage: NextPageWithLayout = () => { + const { query, push } = useRouter() + const { data: session } = useSession() + const { showMessage } = useShowDataStore() + + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useThemeAndDevice(deviceType, deviceOs) + + const [scope, setScope] = useState<'playground' | 'api'>('playground') + const [prompt, setPrompt] = useState('') + + const { botParams, version, modelType, fetchBotParams, resetParams, setDefaultParams, setVersion } = useImageBot(query.slug as string) + const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = useImagesBotFilters() + const { refScrollMobile, refScrollDesktop, mobileScrollContainer, onObserverMounted, setMessages, fetchMessages, loading, offset, messages } = + useMediaBotPagination(deviceType, 'audio') + const { createImage, isComplete, createLoading } = useCreateMediaMessage( + showMessage, + modelType, + 'audio', + deviceType, + setMessages, + mobileScrollContainer + ) + const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput(version, includeParams, createImage) + + async function onFetch() { + const result = await fetchBotParams() + + if (typeof result === 'string' || !result) { + showMessage(result || 'Произошла ошибка') + return push('/404') + } + } + + useEffect(() => { + if (!session) return + onFetch() + onObserverMounted() + }, [session?.access]) + + // Подготавливаем данные для API вкладки + // Фильтруем параметры - оставляем только актуальные для текущей версии + const filteredParams = useMemo(() => { + if (!botParams?.parameters) { + return {} + } + + // Получаем параметры, актуальные для текущей версии + const validParams = botParams.parameters.filter((param) => { + // Если у параметра нет версий - актуален для всех + if (param.versions.length === 0) return true + + // Если нет выбранной версии - показываем все параметры + if (!version) return true + + // Проверяем, актуален ли параметр для текущей версии + return param.versions.includes(version) + }) + + // Создаем объект: если есть значение в Redux - берем его, иначе - дефолтное + return validParams.reduce( + (acc, param) => ({ + ...acc, + // @ts-ignore + [param.key]: includeParams?.[param.key] ?? param.values.default, + }), + {} + ) + }, [botParams?.parameters, version, includeParams]) + + const showFileExample = useMemo(() => { + if (!botParams?.inputs) return false + + // Проверяем, есть ли типы кроме 'text', которые доступны для текущей версии + return botParams.inputs.some((input) => { + // Пропускаем текстовый тип + if (input.type === 'text') return false + + // Если у input нет версий - актуален для всех + if (input.versions.length === 0) return true + + // Если нет выбранной версии - показываем все inputs + if (!version) return true + + // Проверяем, актуален ли input для текущей версии + return input.versions.includes(version) + }) + }, [botParams?.inputs, version]) + + return ( + <> + + {botParams ? botParams?.title : 'Загрузка...'} + +
+ + {botParams && + botParams.tags.map((tag, index) => ( + <div key={index} className={styles.tag}> + <SvgIcon width={23} height={23} url={tag.icon} className={styles.tag__icon} /> + + <span className={styles.tag__text}>{tag.title}</span> + </div> + ))} + </div> + } + /> + </div> + <Box + display={'flex'} + justifyContent='space-between' + alignItems='start' + flexDirection={desktop ? 'row' : 'column-reverse'} + sx={{ + marginBottom: desktop ? 0 : 2, + width: desktop ? '97%' : '100%', + marginTop: desktop ? 3 : '15px', + }} + > + <Box + sx={{ + width: desktop ? '73%' : '100%', + marginLeft: 0, + display: 'flex', + flexDirection: desktop ? 'column' : 'column-reverse', + }} + > + {desktop ? ( + <> + <Stack + alignItems='center' + sx={{ + marginBottom: '15px', + position: 'relative', + zIndex: 1, + }} + > + {botParams && ( + <ModelInput + currentVersion={version} + styles={'images'} + input_types={botParams.inputs} + image={image} + value={prompt} + onValueChange={(value: string) => setPrompt(value)} + desktop={desktop} + blocked={scope === 'playground' ? botParams.blocked : true} + loading={createLoading} + imageLoad={onLoadImage} + sendMessage={onCreateImage} + unpinImage={() => setImage(null)} + viewMobileSettings={() => setOpenFiltersMobile(true)} + /> + )} + {botParams?.blocked && ( + <div className={styles.blocked}> + <BlockedSvg width={16} height={16} color='white' /> + <span className={c(styles.blocked__text)}>Модель недоступна</span> + </div> + )} + </Stack> + <Box + className={'bg-color-block border-radius-main'} + sx={{ + padding: '30px', + position: 'relative', + }} + > + <Box sx={{ display: scope === 'api' ? 'block' : 'none' }}> + <ModelApiView + version={version || ''} + slug={botParams?.slug || ''} + APIModel='image' + modelParams={filteredParams} + showFileExample={showFileExample} + /> + </Box> + <Box sx={{ display: scope === 'playground' ? 'block' : 'none' }}> + <AudioMessagesList + device={deviceType} + audios={messages} + getMessagesPagination={fetchMessages} + /> + </Box> + <Box + sx={{ + position: 'absolute', + bottom: '0', + visibility: 'hidden', + height: '800px', + width: '100%', + }} + ref={refScrollDesktop} + ></Box> + </Box> + </> + ) : ( + <Box className={'bg-color-block border-radius-main'} sx={{ position: 'relative' }}> + <div className={c(styles.container, loading && offset.current === 0 && styles.container_active)}> + <div + style={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }} + > + <Loader /> + </div> + </div> + <div style={{ position: 'relative', borderRadius: '13px' }}> + <Box + sx={{ + padding: '30px', + height: `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 23.5px'})`, + overflowY: 'scroll', + overflowX: 'hidden', + position: 'relative', + }} + ref={mobileScrollContainer} + className={'smallScroll'} + > + <Box sx={{ display: scope === 'playground' ? 'block' : 'none' }}> + <div style={{ position: 'absolute', top: 300 }} ref={refScrollMobile}></div> + <AudioMessagesList + device={deviceType} + audios={messages} + getMessagesPagination={fetchMessages} + /> + </Box> + + <Box sx={{ display: scope === 'api' ? 'block' : 'none' }}> + <ModelApiView + version={version || ''} + slug={botParams?.slug || ''} + APIModel='image' + modelParams={filteredParams} + showFileExample={showFileExample} + /> + </Box> + </Box> + + <Stack alignItems='center' sx={{ zIndex: 10, position: 'relative', margin: 1.25 }}> + {botParams && ( + <ModelInput + currentVersion={version} + styles={'images'} + input_types={botParams.inputs} + image={image} + blocked={scope === 'playground' ? botParams.blocked : true} + value={prompt} + onValueChange={(value: string) => setPrompt(value)} + desktop={desktop} + loading={createLoading} + imageLoad={onLoadImage} + sendMessage={onCreateImage} + unpinImage={() => setImage(null)} + viewMobileSettings={() => setOpenFiltersMobile(true)} + /> + )} + {botParams?.blocked && ( + <div className={styles.blocked}> + <BlockedSvg width={21} height={21} color='white' /> + <span className={c(styles.blocked__text)}>Модель недоступна</span> + </div> + )} + </Stack> + </div> + </Box> + )} + </Box> + + <Stack spacing={2} sx={{ width: desktop ? '25.5%' : '100%', paddingBottom: desktop ? '' : '15px' }}> + <Stack + sx={{ display: 'flex', alignItems: 'center', width: '100%', marginBottom: '15px', marginTop: desktop ? '0px' : '10px' }} + > + <StaticTabs scope={scope} setScope={setScope} /> + </Stack> + {desktop && ( + <Stack spacing={2} className='pd-30 bg-color-block border-radius-main' sx={{ height: 'auto' }}> + {botParams?.versions && botParams.versions.length !== 0 ? ( + <> + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + }} + > + ВЕРСИИ + </Typography> + <ChatSelect setDefaultParams={setDefaultParams} value={version} list={botParams.versions} setValue={setVersion} /> + </> + ) : ( + <></> + )} + {botParams && botParams.parameters?.length > 0 && ( + <Box + display={'flex'} + alignItems={'center'} + gap={'5px'} + sx={{ cursor: 'pointer' }} + onClick={() => { + setParams(!params) + }} + > + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + }} + > + ПАРАМЕТРЫ + </Typography> + <svg + width='14' + height='20' + viewBox='0 0 21 13' + fill='none' + xmlns='http://www.w3.org/2000/svg' + className={`${params ? 'rotate-180' : 'rotate-0'}`} + > + <path + fillRule='evenodd' + clipRule='evenodd' + d='M0.614851 0.615358C1.00866 0.221668 1.54271 0.000505666 2.09955 0.000505642C2.6564 0.000505617 3.19044 0.221668 3.58425 0.615357L10.4996 7.53066L17.4149 0.615357C17.8109 0.232825 18.3414 0.0211567 18.892 0.0259414C19.4426 0.0307261 19.9693 0.25158 20.3587 0.640937C20.748 1.03029 20.9689 1.557 20.9737 2.10761C20.9784 2.65823 20.7668 3.18869 20.3842 3.58476L11.9843 11.9848C11.5904 12.3784 11.0564 12.5996 10.4996 12.5996C9.94271 12.5996 9.40866 12.3784 9.01485 11.9848L0.614851 3.58476C0.221162 3.19095 -4.3461e-07 2.6569 -4.5895e-07 2.10006C-4.8329e-07 1.54321 0.221162 1.00917 0.614851 0.615358Z' + fill='#7f7df3' + /> + </svg> + </Box> + )} + + {botParams && botParams.parameters?.length > 0 ? ( + <Collapse in={params} orientation='vertical' collapsedSize={0}> + <BotParamsMap currentVersion={version} params={botParams?.parameters} /> + <ResetFilters desktop={desktop} closeDrawer={() => setOpenFiltersMobile(false)} reset={resetParams} /> + </Collapse> + ) : ( + <Typography + sx={{ + color: '#6e6e6e', + fontSize: '15px', + fontWeight: '500', + }} + > + Параметры отсутствуют + </Typography> + )} + </Stack> + )} + </Stack> + <DrawerCustom open={openFiltersMobile} onClose={() => setOpenFiltersMobile(false)}> + <Stack spacing={1} padding={2.4}> + {botParams?.versions && botParams.versions.length !== 0 ? ( + <> + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + margin: '20px 0px 0px !important', + }} + > + ВЕРСИИ + </Typography> + <ChatSelect setDefaultParams={setDefaultParams} value={version} list={botParams.versions} setValue={setVersion} /> + </> + ) : ( + <></> + )} + {botParams && botParams.parameters?.length > 0 ? ( + <> + <Typography + sx={{ + color: '#A4AAB5', + fontWeight: '600', + fontSize: '14px', + letterSpacing: '0.1px', + margin: '20px 0px 0px !important', + }} + > + ПАРАМЕТРЫ + </Typography> + <BotParamsMap currentVersion={version} params={botParams?.parameters} /> + <ResetFilters closeDrawer={() => setOpenFiltersMobile(false)} desktop={desktop} reset={resetParams} /> + </> + ) : ( + <Typography + sx={{ + color: '#6e6e6e', + fontSize: '15px', + fontWeight: '500', + }} + > + Параметры отсутствуют + </Typography> + )} + </Stack> + </DrawerCustom> + </Box> + </> + ) +} + +export default AudioModelPage + @@ -0,0 +1,32 @@ +.apiButton { + margin-left: 15px; + margin-top: 25px; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + + align-items: flex-start; + justify-content: flex-start; + justify-items: stretch; + + @media screen and (max-width: 1000px) { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + } + gap: 20px; + padding-top: 40px; + + margin-right: 60px; + + @media screen and (min-width: 1820px) { + margin-right: 240px; + grid-template-columns: repeat(auto-fit, 360px); + } + + @media screen and (max-width: 1000px) { + margin-right: 0px; + } +} + + @@ -1,41 +1,68 @@ import * as React from 'react' import { useEffect, useState } from 'react' import { Box, Typography } from '@mui/material' +import CircularProgress from '@mui/material/CircularProgress' import { useSession } from 'next-auth/react' import { useAppSelector } from '#/app/store/store' import { getAudio } from '#/entities/audio-model' -import { ImageModelCard, IShortModel } from '#/entities/model-entity' +import { AudioModelCard, IShortModel } from '#/entities/model-entity' import { NextPageWithLayout } from '#/pages/_app' +import styles from './audio-models.module.scss' +import { useThemeAndDevice } from '#/shared/lib/hooks' +import { getDeviceType, getOs } from '#/shared/lib/helpers' + export const AudioModelsPage: NextPageWithLayout = () => { - const [bots, setBots] = useState<IShortModel[] | null>(null) + const [models, setModels] = useState<IShortModel[] | null>(null) + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useThemeAndDevice(deviceType, deviceOs) const { data, status } = useSession() - const payment_plan = useAppSelector((state) => state.user.payment_plan) useEffect(() => { if (!data) return - getAudio(data.access).then((res) => setBots(res)) + getAudio(data.access).then((res) => setModels(res)).catch(() => { + setModels([]) + }) }, [status]) return ( <> - <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}> - Аудио - </Typography> - <Box display='flex' flexWrap='wrap'> - {bots?.map((item, idx) => { - return ( - <ImageModelCard + <Box + sx={{ + display: 'flex', + alignItems: 'center', + }} + > + <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}>Аудио</Typography> + </Box> + <Box className={styles.cards} sx={{ position: 'relative', marginRight: desktop ? '60px' : '0px' }}> + {models ? ( + models.map((item, index) => ( + <AudioModelCard accessed_models={payment_plan.plan.accessed_models} - key={idx} + key={item.slug || item.title || index} {...item} /> - ) - })} + )) + ) : ( + <CircularProgress + size={50} + thickness={3} + sx={{ + color: '#7F7DF3', + position: 'absolute', + top: '40%', + left: 0, + right: 0, + margin: '0 auto', + }} + /> + )} </Box> </> ) @@ -1 +1,2 @@ export { default as AudioModelsPage } from './audio-models' +export { default as AudioModelPage } from './audio-model' @@ -36,7 +36,7 @@ const Page: NextPageWithLayout = () => { </Box> <div className={styles.cards}> {bots ? ( - bots.map((item) => <ChatModelCard accessed_models={payment_plan.plan.accessed_models} key={item.uid} {...item} />) + bots.map((item, index) => <ChatModelCard accessed_models={payment_plan.plan.accessed_models} key={item.slug || item.title || index} {...item} />) ) : ( <CircularProgress size={50} @@ -34,8 +34,8 @@ export const ImageModelsPage: NextPageWithLayout = () => { </Box> <div className={styles.cards}> {bots ? ( - bots.map((item) => { - return <ImageModelCard accessed_models={payment_plan.plan.accessed_models} {...item} key={item.uid} /> + bots.map((item, index) => { + return <ImageModelCard accessed_models={payment_plan.plan.accessed_models} {...item} key={item.uid || item.slug || index} /> }) ) : ( <CircularProgress @@ -42,15 +42,13 @@ export const VideoModelsPage: NextPageWithLayout = () => { </Box> <div className={styles.cards}> {models ? ( - models?.map((item) => { - return ( - <VideoModelCard - accessed_models={payment_plan.plan.accessed_models} - {...item} - key={item.uid} - /> - ) - }) + models.map((item, index) => ( + <VideoModelCard + accessed_models={payment_plan.plan.accessed_models} + {...item} + key={item.slug || item.title || index} + /> + )) ) : ( <CircularProgress size={50} @@ -0,0 +1,178 @@ +import React, { memo, useRef, useState } from 'react' +import { Skeleton, Typography } from '@mui/material' +import Box from '@mui/material/Box' + +import { AudioPlayer, AudioPlayerRef } from './audio-player' + +import { useAppSelector } from '#/app/store/store' +import { Message } from '#/entities/message' +import { TooltipCustom } from '#/shared' + +interface MessagesList { + device: 'mobile' | 'desktop' + audios: Message[] | null | undefined + getMessagesPagination?: () => Promise<void> +} + +export const AudioMessagesList: React.FC<MessagesList> = memo(({ device, audios }) => { + const [loadedAudios, setLoadedAudios] = useState<Set<string>>(new Set()) + const [currentlyPlaying, setCurrentlyPlaying] = useState<string | null>(null) + const audioRefs = useRef<{ [key: string]: (HTMLAudioElement & AudioPlayerRef) | null }>({}) + const theme = useAppSelector((state) => state.theme.theme) + + const handleAudioLoad = (uid: string) => { + setLoadedAudios((prev) => new Set([...prev, uid])) + } + + const handlePlay = (uid: string) => { + // Если уже играет другая запись, останавливаем её + if (currentlyPlaying && currentlyPlaying !== uid) { + const previousAudio = audioRefs.current[currentlyPlaying] + if (previousAudio) { + previousAudio.pause() + } + } + setCurrentlyPlaying(uid) + } + + const handlePause = (uid: string) => { + // Если ставится на паузу текущая запись, очищаем состояние + if (currentlyPlaying === uid) { + setCurrentlyPlaying(null) + } + } + + return ( + <Box className={'mt-15'}> + <Typography + sx={{ + paddingBottom: '30px', + fontSize: '16px', + color: '#A4AAB5', + fontWeight: '600', + letterSpacing: '0.3px', + }} + > + ГЕНЕРАЦИИ + </Typography> + + <Box + id={'messages-list'} + sx={{ + display: 'flex', + flexDirection: 'column', + gap: '20px', + }} + > + {audios?.length !== 0 && + audios?.map((message) => { + // Если нет файла или файл пустой, не показываем сообщение + if (!message.file || (typeof message.file === 'string' && message.file.trim() === '')) { + return null + } + + const fileUrl = message.file as string + return ( + <Box + key={message.uid} + onMouseLeave={() => { + audioRefs.current[message.uid]?.setVolumeHovered(false) + }} + sx={{ + display: 'flex', + flexDirection: 'column', + gap: '12px', + }} + > + <Box + sx={{ + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: '12px', + padding: '12px', + borderRadius: '15px', + backgroundColor: theme === 'dark' ? '#1E1E20' : '#F5F5F7', + width: '100%', + }} + > + {!loadedAudios.has(message.uid) && ( + <Skeleton + sx={{ + background: theme === 'dark' ? '#2d2d2f' : '#EFF0F2', + borderRadius: '8px', + width: '100%', + height: '48px', + }} + variant='rectangular' + /> + )} + <AudioPlayer + ref={(el) => { + audioRefs.current[message.uid] = el as (HTMLAudioElement & AudioPlayerRef) | null + }} + onLoadedData={() => handleAudioLoad(message.uid)} + onPlayStart={handlePlay} + onPauseStart={handlePause} + uid={message.uid} + url={fileUrl} + content={message.content} + buttonText='Скачать аудио' + device={device} + style={{ + width: '100%', + height: '48px', + opacity: loadedAudios.has(message.uid) ? 1 : 0, + }} + > + <source src={fileUrl} type='audio/mpeg' /> + <source src={fileUrl} type='audio/wav' /> + <source src={fileUrl} type='audio/ogg' /> + Ваш браузер не поддерживает аудио. + </AudioPlayer> + </Box> + {message.content && message.content.length > 0 && (() => { + const cleanContent = message.content.replaceAll('"', '') + const isDesktop = device === 'desktop' + + const displayText = cleanContent + + const hasMoreContent = isDesktop + ? cleanContent.includes('\n') + : cleanContent.length > 50 + + const fullTextForTooltip = cleanContent + + return ( + <TooltipCustom + key={message.uid} + placement='right' + title={hasMoreContent || fullTextForTooltip.length > 50 ? fullTextForTooltip : ''} + > + <Typography + sx={{ + fontSize: '15px', + color: theme === 'dark' ? '#D4D4D4' : '#555556', + marginBottom: '8px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: isDesktop ? '100%' : '90%', + }} + > + {displayText} + {!isDesktop && hasMoreContent && '...'} + </Typography> + </TooltipCustom> + ) + })()} + </Box> + ) + })} + </Box> + </Box> + ) +}) + +AudioMessagesList.displayName = 'AudioMessagesList' + @@ -0,0 +1,125 @@ +.container { + display: flex; + align-items: center; + width: 100%; + height: 48px; + gap: 16px; + padding: 0 12px; + background-color: transparent; +} + +.audio { + display: none; +} + +.playButton { + width: 42px; + height: 42px; + color: #FFFFFF; + + &:hover { + background-color: #6d6be0; + } +} + +.contentWrapper { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.timeLabels { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: 4px; +} + +.timeLabel { + font-size: 12px; +} + +.progressSlider { + + padding-top: 4px; + & :global(.MuiSlider-track) { + border: none; + height: 6px; + } + + & :global(.MuiSlider-rail) { + opacity: 1; + height: 6px; + } + + & :global(.MuiSlider-thumb) { + width: 10px; + height: 10px; + background-color: #7F7DF3; + box-shadow: 0 0 0 4px rgba(127, 125, 243, 0.18); + + &:hover, + &:global(.Mui-focusVisible), + &:global(.Mui-active) { + box-shadow: 0 0 0 6px rgba(127, 125, 243, 0.28); + } + } +} + +.volumeContainer { + position: relative; + display: flex; + align-items: center; +} + +.volumeSliderWrapper { + position: absolute; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + z-index: 1000; + pointer-events: auto; +} + +.volumeSlider { + height: 84px; + + & :global(.MuiSlider-rail) { + opacity: 1; + width: 8px; + } + + & :global(.MuiSlider-track) { + width: 8px; + } + + & :global(.MuiSlider-thumb) { + display: none; + } +} + +.menuContainer { + position: relative; +} + +.menuButton { + cursor: pointer; +} + +.menuDropdown { + z-index: 1000; + position: absolute; + top: 40px; + right: 0px; + padding: 12px 9px; + border-radius: 60px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.menuIcon { + cursor: pointer; +} + @@ -0,0 +1,390 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' +import { Box, Grow, IconButton, Slider, SliderProps, Typography } from '@mui/material' +import { Pause, PlayArrow, VolumeOff, VolumeUp } from '@mui/icons-material' +import type { SxProps, Theme } from '@mui/material/styles' + +import styles from './audio-player.module.scss' + +import { useAppSelector } from '#/app/store/store' +import { TooltipCustom } from '#/shared' +import { useImageIcons } from '../model' +import { c } from '#/shared/lib/helpers' +import { baseColor } from '#/shared/lib/constants/colors' + +export type AudioPlayerRef = { + setVolumeHovered: (hovered: boolean) => void + pause: () => void + play: () => Promise<void> +} + +type AudioPlayerProps = Omit<React.AudioHTMLAttributes<HTMLAudioElement>, 'controls'> & { + containerSx?: SxProps<Theme> + uid?: string + url?: string | null + content?: string + buttonText?: string + device?: 'mobile' | 'desktop' + onPlayStart?: (uid: string) => void + onPauseStart?: (uid: string) => void +} + +const formatTime = (seconds: number) => { + if (!Number.isFinite(seconds) || seconds < 0) return '0:00' + + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + + return `${mins}:${secs.toString().padStart(2, '0')}` +} + +const AudioPlayerBase = ( + { + children, + style, + className, + onLoadedData, + containerSx, + uid, + url, + content, + buttonText, + device = 'desktop', + onPlayStart, + onPauseStart, + ...rest + }: AudioPlayerProps, + ref: React.Ref<HTMLAudioElement | null> +) => { + const audioRef = useRef<HTMLAudioElement | null>(null) + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [volume, setVolume] = useState(1) + const [muted, setMuted] = useState(false) + const [volumeHovered, setVolumeHovered] = useState(false) + const theme = useAppSelector((state) => state.theme.theme) + const { toggleMenu, downloadFile, iconsMenu } = useImageIcons() + + const palette = useMemo( + () => ({ + controlBg: baseColor, + controlBgHover: '#6d6be0', + textSecondary: theme === 'dark' ? '#A4AAB5' : '#6C727F', + progressRail: theme === 'dark' ? '#3A3A3F' : '#D5D7E0', + }), + [theme] + ) + const composedContainerSx = containerSx + + useImperativeHandle( + ref, + () => { + const audio = audioRef.current + return { + ...(audio || ({} as HTMLAudioElement)), + setVolumeHovered, + pause: () => { + if (audio) { + audio.pause() + } + }, + play: () => { + if (audio) { + return audio.play() + } + return Promise.resolve() + }, + } as HTMLAudioElement & AudioPlayerRef + }, + [] + ) + + useEffect(() => { + const audio = audioRef.current + if (!audio) return + + const handleTimeUpdate = () => setCurrentTime(audio.currentTime || 0) + const handleLoadedMetadata = () => { + setDuration(audio.duration || 0) + } + const handlePlay = () => { + setIsPlaying(true) + if (onPlayStart && uid) { + onPlayStart(uid) + } + } + const handlePause = () => { + setIsPlaying(false) + if (onPauseStart && uid) { + onPauseStart(uid) + } + } + + const handleLoadedData = () => { + setDuration(audio.duration || 0) + } + + audio.addEventListener('timeupdate', handleTimeUpdate) + audio.addEventListener('loadedmetadata', handleLoadedMetadata) + audio.addEventListener('loadeddata', handleLoadedData) + audio.addEventListener('play', handlePlay) + audio.addEventListener('pause', handlePause) + + return () => { + audio.removeEventListener('timeupdate', handleTimeUpdate) + audio.removeEventListener('loadedmetadata', handleLoadedMetadata) + audio.removeEventListener('loadeddata', handleLoadedData) + audio.removeEventListener('play', handlePlay) + audio.removeEventListener('pause', handlePause) + } + }, [onPlayStart, onPauseStart, uid]) + + const togglePlay = useCallback(() => { + const audio = audioRef.current + if (!audio) return + + if (audio.paused) { + audio + .play() + .catch(() => {}) + } else { + audio.pause() + } + }, []) + + const handleSeek: SliderProps['onChange'] = (_, value) => { + const audio = audioRef.current + if (!audio || typeof value !== 'number') return + + audio.currentTime = value + setCurrentTime(value) + } + + const handleVolumeChange: SliderProps['onChange'] = (_, value) => { + const audio = audioRef.current + if (!audio || typeof value !== 'number') return + + audio.volume = value + setVolume(value) + if (value > 0 && muted) { + audio.muted = false + setMuted(false) + } else if (value === 0) { + setMuted(true) + } + } + + const toggleMute = useCallback(() => { + const audio = audioRef.current + if (!audio) return + + audio.muted = !audio.muted + setMuted(audio.muted) + if (!audio.muted && audio.volume === 0) { + audio.volume = 1 + setVolume(1) + } + }, []) + + const handleMenuClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (uid) { + toggleMenu(uid) + } + }, + [uid, toggleMenu] + ) + + const handleOpenInNewTab = useCallback(() => { + if (url) { + window.open(url, '_blank') + } + if (uid) { + toggleMenu(uid) + } + }, [url, uid, toggleMenu]) + + const handleDownload = useCallback(() => { + downloadFile(url ?? null, content) + if (uid) { + toggleMenu(uid) + } + }, [url, content, uid, downloadFile, toggleMenu]) + + const handleLoadedDataInternal: React.ReactEventHandler<HTMLAudioElement> = useCallback( + (event) => { + const audio = event.currentTarget + setDuration(audio.duration || 0) + + if (onLoadedData) { + onLoadedData(event) + } + }, + [onLoadedData] + ) + + const formattedCurrentTime = useMemo(() => formatTime(currentTime), [currentTime]) + const formattedDuration = useMemo(() => formatTime(duration || currentTime), [duration, currentTime]) + + return ( + <Box className={c(styles.container, className)} sx={composedContainerSx} style={style}> + <audio ref={audioRef} {...rest} onLoadedData={handleLoadedDataInternal} controls={false} className={styles.audio}> + {children} + </audio> + + <IconButton + onClick={togglePlay} + className={styles.playButton} + sx={{ backgroundColor: palette.controlBg }} + aria-label={isPlaying ? 'Пауза' : 'Воспроизвести'} + > + {isPlaying ? <Pause fontSize='small' /> : <PlayArrow fontSize='small' />} + </IconButton> + + <Box className={styles.contentWrapper}> + <Box className={styles.timeLabels}> + <Typography variant='body2' className={styles.timeLabel} sx={{ color: palette.textSecondary }}> + {formattedCurrentTime} + </Typography> + <Typography variant='body2' className={styles.timeLabel} sx={{ color: palette.textSecondary }}> + {formattedDuration} + </Typography> + </Box> + <Slider + size='small' + step={0.01} + value={duration ? Math.min(currentTime, duration) : 0} + min={0} + max={duration || 0} + onChange={handleSeek} + className={styles.progressSlider} + sx={{ + color: palette.controlBg, + '& .MuiSlider-rail': { + backgroundColor: palette.progressRail, + }, + }} + /> + </Box> + + {device === 'desktop' && ( + <Box className={styles.volumeContainer} onMouseEnter={() => setVolumeHovered(true)}> + <IconButton + onClick={toggleMute} + className={styles.volumeButton} + sx={{ color: palette.textSecondary }} + aria-label={muted || volume === 0 ? 'Включить звук' : 'Выключить звук'} + > + {muted || volume === 0 ? <VolumeOff fontSize='small' /> : <VolumeUp fontSize='small' />} + </IconButton> + + {volumeHovered && ( + <Box onMouseEnter={() => setVolumeHovered(true)} className={styles.volumeSliderWrapper}> + <Slider + size='small' + orientation='vertical' + value={muted ? 0 : volume} + min={0} + max={1} + step={0.01} + onChange={handleVolumeChange} + className={styles.volumeSlider} + sx={{ + color: palette.controlBg, + '& .MuiSlider-rail': { + backgroundColor: palette.progressRail, + }, + }} + /> + </Box> + )} + </Box> + )} + + <Box className={styles.menuContainer}> + <svg + onClick={handleMenuClick} + className={styles.menuButton} + width='35' + height='35' + viewBox='0 0 35 35' + fill='none' + xmlns='http://www.w3.org/2000/svg' + aria-label='Дополнительные действия' + > + <circle cx='17.5' cy='17.5' r='17.5' fill={baseColor} /> + <path + fillRule='evenodd' + clipRule='evenodd' + d='M11 12.4377C11 12.1957 11.0978 11.9637 11.272 11.7926C11.4461 11.6215 11.6823 11.5254 11.9286 11.5254H23.0714C23.3177 11.5254 23.5539 11.6215 23.728 11.7926C23.9022 11.9637 24 12.1957 24 12.4377C24 12.6796 23.9022 12.9117 23.728 13.0828C23.5539 13.2538 23.3177 13.35 23.0714 13.35H11.9286C11.6823 13.35 11.4461 13.2538 11.272 13.0828C11.0978 12.9117 11 12.6796 11 12.4377ZM11 16.9991C11 16.7571 11.0978 16.5251 11.272 16.354C11.4461 16.1829 11.6823 16.0868 11.9286 16.0868H23.0714C23.3177 16.0868 23.5539 16.1829 23.728 16.354C23.9022 16.5251 24 16.7571 24 16.9991C24 17.241 23.9022 17.4731 23.728 17.6442C23.5539 17.8152 23.3177 17.9114 23.0714 17.9114H11.9286C11.6823 17.9114 11.4461 17.8152 11.272 17.6442C11.0978 17.4731 11 17.241 11 16.9991ZM11 21.5605C11 21.3185 11.0978 21.0865 11.272 20.9154C11.4461 20.7443 11.6823 20.6482 11.9286 20.6482H23.0714C23.3177 20.6482 23.5539 20.7443 23.728 20.9154C23.9022 21.0865 24 21.3185 24 21.5605C24 21.8024 23.9022 22.0345 23.728 22.2056C23.5539 22.3766 23.3177 22.4728 23.0714 22.4728H11.9286C11.6823 22.4728 11.4461 22.3766 11.272 22.2056C11.0978 22.0345 11 21.8024 11 21.5605Z' + fill='#FFFFFF' + /> + </svg> + + {uid && ( + <Grow in={iconsMenu === uid}> + <Box + className={styles.menuDropdown} + sx={{ + background: theme === 'dark' ? '#303035' : '#FFFFFF', + display: iconsMenu === uid ? 'flex' : 'none', + }} + > + <TooltipCustom title={'Открыть в новой вкладке'} placement={'right'}> + <svg + onClick={handleOpenInNewTab} + className={styles.menuIcon} + width='15' + height='15' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + <path + d='M5.875 2.625H2.625C2.19402 2.625 1.7807 2.7962 1.47595 3.10095C1.1712 3.4057 1 3.81902 1 4.25V12.375C1 12.806 1.1712 13.2193 1.47595 13.524C1.7807 13.8288 2.19402 14 2.625 14H10.75C11.181 14 11.5943 13.8288 11.899 13.524C12.2038 13.2193 12.375 12.806 12.375 12.375V9.125M9.125 1H14M14 1V5.875M14 1L5.875 9.125' + stroke='#827FFF' + strokeWidth='2' + strokeLinecap='round' + strokeLinejoin='round' + /> + </svg> + </TooltipCustom> + <TooltipCustom title={buttonText || 'Скачать аудио'} placement={'right'}> + <svg + onClick={handleDownload} + className={styles.menuIcon} + width='15' + height='15' + viewBox='0 0 15 15' + fill='none' + xmlns='http://www.w3.org/2000/svg' + > + <path + d='M1 10.75V11.5625C1 12.209 1.25681 12.829 1.71393 13.2861C2.17105 13.7432 2.79103 14 3.4375 14H11.5625C12.209 14 12.829 13.7432 13.2861 13.2861C13.7432 12.829 14 12.209 14 11.5625V10.75M10.75 7.5L7.5 10.75M7.5 10.75L4.25 7.5M7.5 10.75V1' + stroke='#827FFF' + strokeWidth='2' + strokeLinecap='round' + strokeLinejoin='round' + /> + </svg> + </TooltipCustom> + </Box> + </Grow> + )} + </Box> + </Box> + ) +} + +export const AudioPlayer = forwardRef<HTMLAudioElement, AudioPlayerProps>(AudioPlayerBase) + +AudioPlayer.displayName = 'AudioPlayer' @@ -49,6 +49,12 @@ export const menuListMiddle = [ icon: '/svg/side-menu/video', activeList: ['videos'], }, + { + title: 'Аудио', + link: '/audio', + icon: '/svg/side-menu/audio', + activeList: ['audio'], + }, ] const drawerWidth = 240 @@ -100,7 +106,7 @@ const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' export const SideMenu = ({}) => { const { status } = useSession() const { pathname, asPath } = useRouter() - const { theme } = useAppSelector((state) => state) + const theme = useAppSelector((state) => state.theme.theme) const { getOptionValue, settings, updateSettings } = useUserSettingsContext() const error_report = getModalById(ERROR_REPORT) @@ -114,7 +120,7 @@ export const SideMenu = ({}) => { className={styles.drawer} PaperProps={{ sx: { - backgroundColor: theme.theme === 'light' ? 'white' : '#151518', + backgroundColor: theme === 'light' ? 'white' : '#151518', border: 'none', }, }} @@ -168,7 +174,7 @@ export const SideMenu = ({}) => { key={el.title} open={open === 'opened'} pathname={asPath} - theme={theme.theme} + theme={theme} title={el.title} link={el.link} icon={el.icon} @@ -183,7 +189,7 @@ export const SideMenu = ({}) => { key={el.title} open={open === 'opened'} pathname={asPath} - theme={theme.theme} + theme={theme} title={el.title} link={el.link} icon={el.icon} @@ -202,7 +208,7 @@ export const SideMenu = ({}) => { <MenuItem open={open === 'opened'} pathname={pathname} - theme={theme.theme} + theme={theme} title={'Поддержка'} link={null} icon={'/svg/side-menu/side-question'} @@ -216,7 +222,7 @@ export const SideMenu = ({}) => { <MenuItem open={open === 'opened'} pathname={pathname} - theme={theme.theme} + theme={theme} title={'Компаниям'} link={null} icon={'/svg/side-menu/for-business'} @@ -224,8 +230,8 @@ export const SideMenu = ({}) => { </Box> <MenuItem open={open === 'opened'} - pathname={pathname} - theme={theme.theme} + pathname={pathname} + theme={theme} title={'API-ключи'} link={'/api-keys'} icon={'/svg/side-menu/api-keys'}