Binary files a/public/images/dashboard.png and b/public/images/dashboard.png differ @@ -1,11 +1,12 @@ -import axios from 'axios' +import axios, { AxiosResponse } from 'axios' import { IShortModel } from '#/entities/model-entity' +import { MessageSend, sendMediaMessage } from '#/entities/message' import { getApiUrl } from '#/shared/lib/constants' -export async function getAudio(token?: string): Promise { +async function fetchMlModelsByCategory(category: string, token?: string): Promise { try { - const { data } = await axios.get(getApiUrl() + '/ml_models/?category=audio', { + const { data } = await axios.get(getApiUrl() + `/ml_models/?category=${encodeURIComponent(category)}`, { headers: { Authorization: `Bearer ${token}`, }, @@ -15,3 +16,97 @@ export async function getAudio(token?: string): Promise { return [] } } + +export async function getAudio(token?: string): Promise { + return fetchMlModelsByCategory('audio', token) +} + +export async function getVoiceCloneModels(token?: string): Promise { + return fetchMlModelsByCategory('voice', token) +} + +export interface MediaVoiceItem { + uid: string + title: string + file: string +} + +export async function getMediaVoices(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/voices/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getMediaPresets(token?: string): Promise { + try { + const { data } = await axios.get(getApiUrl() + '/api/media/presets/?kind=voice', { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return Array.isArray(data) ? data : [] + } catch { + return [] + } +} + +export async function getVoicesAndPresets(token?: string): Promise<{ voices: MediaVoiceItem[]; presets: MediaVoiceItem[] }> { + const [voices, presets] = await Promise.all([getMediaVoices(token), getMediaPresets(token)]) + return { voices, presets } +} + +/** POST /api/media/voices/ — загрузить голос (file обязателен: mp3, ogg, wav; title опционален) */ +export async function createMediaVoice(file: File, token?: string, title?: string): Promise> { + const formData = new FormData() + formData.append('file', file) + if (title != null && title !== '') { + formData.append('title', title) + } + + return axios.post(getApiUrl() + '/api/media/voices/', formData, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** DELETE /api/media/voices/{voice_id}/ */ +export async function deleteMediaVoice(voiceId: string, token?: string): Promise> { + return axios.delete(getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + +/** PATCH /api/media/voices/{voice_id}/ — сменить title */ +export async function patchMediaVoiceTitle(voiceId: string, title: string, token?: string): Promise> { + return axios.patch( + getApiUrl() + `/api/media/voices/${encodeURIComponent(voiceId)}/`, + { title }, + { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ) +} + +/** POST /media/voice/{model} — генерация (тот же FormData/JSON, что у остальных media POST) */ +export function postVoiceCloneGeneration(model: string | null, dataForSend: MessageSend | FormData, token?: string) { + return sendMediaMessage(model, 'voice', dataForSend, token) +} @@ -4,9 +4,12 @@ import { Message, MessageSend } from '../types' import { getApiUrl } from '#/shared/lib/constants' - - -export async function sendMediaMessage(model: string | null, modelType: 'video' | 'image' | 'audio', dataForSend: MessageSend | FormData, token?: string) { +export async function sendMediaMessage( + model: string | null, + modelType: 'video' | 'image' | 'audio' | 'voice', + dataForSend: MessageSend | FormData, + token?: string +) { const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' return await axios.post(getApiUrl() + `/media/${modelType}/${model}`, dataForSend, { @@ -19,7 +22,7 @@ export async function sendMediaMessage(model: string | null, modelType: 'vide }) } -export async function getImagesBySlug(slug: string, token: string, type:'image' | 'video' | 'audio', offset?: number, limit = 10) { +export async function getImagesBySlug(slug: string, token: string, type: 'image' | 'video' | 'audio' | 'voice', offset?: number, limit = 10) { return await axios.get(getApiUrl() + `/media/${type}/${slug}?limit=${limit}&offset=${offset}`, { validateStatus: (status) => status < 500, headers: { @@ -13,12 +13,23 @@ import styles from './card.module.scss' export interface AudioModelCardProps extends IShortModel { accessed_models: string[] | null + /** Базовый сегмент URL списка моделей (например audio, voice-cloning) */ + modelsBasePath?: string } -export function AudioModelCard({ description, image, title, slug, accessed_models, blocked, tags }: AudioModelCardProps) { +export function AudioModelCard({ + description, + image, + title, + slug, + accessed_models, + blocked, + tags, + modelsBasePath = 'audio', +}: AudioModelCardProps) { const link = useMemo(() => { - return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `audio/${slug}` - }, [accessed_models]) + return accessed_models && !accessed_models.includes(slug) ? '/subscription' : `${modelsBasePath}/${slug}` + }, [accessed_models, slug, modelsBasePath]) return ( @@ -29,7 +29,15 @@ export function useGlobalSettings() { makePrivateRequest(async (type: string, value: any) => { const option = settings.find((x) => x.type === type) - if (!option) return showMessage('Ошибка присвоения настроек') + if (!option) { + // Если настройки нет, создаём её + const { status, data: newSetting } = await postUserSettings({ device, type, value }) + + if (status !== 200) return showMessage('Ошибка создания настроек') + + setSettings((s) => [...s, newSetting]) + return + } const { status } = await updateUserSettings(option.id, value) @@ -37,7 +45,7 @@ export function useGlobalSettings() { setSettings((s) => [...s.filter((x) => x.type !== type), { ...option, value }]) }), - [settings, data] + [settings, data, device] ) const fetchUserSettings = makePrivateRequest(async () => { @@ -33,11 +33,13 @@ export type ResponseAllInfo = { } payment_plan: { uid: string + is_recurring: boolean plan: { uid: string price: string individual: boolean tokens_per_plan: string + is_corporate: boolean title: string duration: string accessed_models: string[] | null @@ -74,10 +76,12 @@ const initialState: UserState & ResponseAllInfo = { account_type: 'regular', payment_plan: { uid: '', + is_recurring: false, plan: { uid: '', price: '', tokens_per_plan: '', + is_corporate: false, individual: false, duration: '', title: '', @@ -9,6 +9,7 @@ import styles2 from '#/widgets/business-persons/ui/persons-list/persons-list.mod import { InviteRoles, inviteRoles } from '../../invite-person-in-business/lib/constants' import { updateLimit } from '../api/set-limit' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' interface LimitModalProps { person: ResponseGetPersons | null @@ -20,28 +21,35 @@ interface LimitModalProps { addList: (newUser: ResponseGetPersons, list: 'personal' | 'security') => void } -export const LimitModal: React.FC = ({ - person, - onClose, - updateLimitProp, - list, - setList, - addList, - listType, -}) => { +export const LimitModal: React.FC = ({ person, onClose, updateLimitProp, list, setList, addList, listType }) => { const [limit, setLimit] = useState(person?.token_limit ? Math.floor(+person.token_limit).toString() : '0') const [role, setRole] = useState('Сотрудник') + const [originalRole, setOriginalRole] = useState('Сотрудник') const { data } = useSession() + const { showMessage } = useShowDataStore() + + const getRoleFromAccountType = (accountType: string | undefined): InviteRoles => { + switch (accountType) { + case 'business_account': + return 'Сотрудник' + case 'business_admin': + return 'Администратор' + case 'business_security': + return 'Сотрудник безопасности' + default: + return 'Сотрудник' + } + } const handleChangeBalance = () => { + const originalRoleValue = getRoleFromAccountType(person?.account_type) + updateLimit(person?.email, limit, data?.access, role).then((res) => { if (res === null) { return } else if (list && res.account_type !== person?.account_type) { if ( - (res.account_type === 'business_host' || - res.account_type === 'business_account' || - res.account_type === 'business_admin') && + (res.account_type === 'business_host' || res.account_type === 'business_account' || res.account_type === 'business_admin') && listType === 'security' ) { setList(list.filter((el) => el.email !== res.email)) @@ -52,19 +60,19 @@ export const LimitModal: React.FC = ({ } } - updateLimitProp(res.token_limit, person?.email) + if (role !== originalRoleValue) { + showMessage(`Роль пользователя ${person?.email} успешно изменена на "${role}"!`, 'success') + } else { + updateLimitProp(res.token_limit, person?.email) + } }) } useEffect(() => { - if (person?.account_type === 'business_account') { - setRole('Сотрудник') - } - if (person?.account_type === 'business_admin') { - setRole('Администратор') - } - if (person?.account_type === 'business_security') { - setRole('Сотрудник безопасности') + if (person) { + const roleFromAccount = getRoleFromAccountType(person.account_type) + setRole(roleFromAccount) + setOriginalRole(roleFromAccount) } }, [person]) @@ -84,13 +92,7 @@ export const LimitModal: React.FC = ({ Изменение лимита токенов - setLimit(e.target.value)} - fullWidth - sx={{ ...InputStyleDark }} - /> + setLimit(e.target.value)} fullWidth sx={{ ...InputStyleDark }} /> @@ -10,7 +10,7 @@ import { formDataHelper } from '#/widgets/messages' export function useCreateMediaMessage( showError: (message: string) => void, type: string, - modelType: 'video' | 'image' | 'audio', + modelType: 'video' | 'image' | 'audio' | 'voice', 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' | 'audio') { +export function useMediaBotPagination(deviceType: Device, type: 'image' | 'video' | 'audio' | 'voice') { const refScrollMobile = useRef(null) const refScrollDesktop = useRef(null) const mobileScrollContainer = useRef(null) @@ -0,0 +1,16 @@ +import { useEffect } from 'react' + +import { getModalById, LOW_BALANCE_OFFER } from '#/features/modals' + +import { LowBalanceOfferPlate } from './low-balance-offer-plate' + + +export const LowBalanceOfferOnStart = () => { + const modal = getModalById(LOW_BALANCE_OFFER) + + useEffect(() => { + modal.setState(true) + }, []) + + return +} @@ -0,0 +1,47 @@ +.container { + width: 100%; + max-width: 570px; + padding: 32px 0 0 0; + display: flex; + flex-direction: column; + background-color: #151518; + align-items: flex-start; +} + +.text { + color: #8B8B8B; + font-size: 17px; + line-height: 1.5; + margin: 16px 0 24px; +} + +.text_heading { + font-weight: 800; + font-size: 48px; + line-height: 42px; +} + +.offersWrapper { + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; + gap: 20px; + width: 100%; + margin-bottom: 24px; + border-radius: 30px; + box-shadow: + 0 0 20px rgba(100, 180, 255, 0.12), + 0 0 40px rgba(80, 150, 255, 0.08), +} + +.link { + font-weight: 600; + font-style: SemiBold; + font-size: 16px; + align-self: center; + + p { + color:#80808E; + } +} @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import { useSession } from 'next-auth/react' + +import { LOW_BALANCE_OFFER, getModalById, PlateTemplate } from '#/features/modals' +import { c } from '#/shared' +import { accountApi } from '#/shared/api/account-endpoints' +import { getDeviceType } from '#/shared/lib/helpers' +import { IOffer } from '#/views/subscription' +import Offer from '#/views/subscription/ui/offer' + +import styles from './low-balance-offer-plate.module.scss' + +export const LowBalanceOfferPlate = () => { + const modal = getModalById(LOW_BALANCE_OFFER) + const { data } = useSession() + const [offers, setOffers] = useState(null) + + useEffect(() => { + if (data?.access) { + accountApi.getPaymentsPlans(data.access).then((res) => { + if (res) setOffers(res) + }) + } + }, [data?.access]) + + const handleClose = () => { + modal.setState(false) + } + + const offerIndex = modal.getStoreProperty('offerIndex') ?? 1 + const offerUid = modal.getStoreProperty('offerUid'); + const notEnoughtTokens = modal.getStoreProperty('notEnoughtTokens') + + const offerToShow = offerUid + ? offers?.find((o) => o.uid === offerUid) + : offerIndex + ? offers?.[offerIndex] + : offers?.[1] + + return ( + +
e.stopPropagation()}> + {notEnoughtTokens ? ( +

У вас не осталось
токенов...

+ ) : ( +

Осталось
мало токенов...

+ )} +

+ Подобрали для Вас наиболее подходящий тариф,
+ взгляните на преимущества! +

+
+ {offers === null ? ( +

Загрузка тарифов...

+ ) : offerToShow ? ( + + ) : null} +
+ +

Нет, я хочу посмотреть все тарифы

+ +
+
+ ) +} @@ -0,0 +1,80 @@ +import { useEffect, useRef } from 'react' +import axios from 'axios' + +import { useAppSelector } from '#/app/store/store' +import { getModalById, LOW_BALANCE_OFFER } from '#/features/modals' +import { LowBalanceOfferPlate } from './low-balance-offer-plate' +import type { ResponseAllInfo } from '#/entities/user-account/model/user-type-slice' +import { useFeatureFlag } from '#/shared/lib/hooks/use-feature-flag' +import { balanceSelector } from '#/shared/lib/selectors' + + +export const LowBalanceOfferTrigger = () => { + const balance = useAppSelector(balanceSelector) + const me = useAppSelector((state) => state.user) as ResponseAllInfo & { status: string; referral: string } + const isFewTokensModalEnabled = useFeatureFlag('few-tokens-modal-enabled') + const prevBalanceRef = useRef(null) + const modal = getModalById(LOW_BALANCE_OFFER) + + useEffect(() => { + const interceptorId = axios.interceptors.response.use( + (response) => { + if (response.status === 402 && response?.data?.detail?.includes('Баланс')) { + invokeLowBalanceOffer(true) + } + return response + }, + ) + + return () => { + axios.interceptors.response.eject(interceptorId) + } + }, []) + + useEffect(() => { + + if (!isFewTokensModalEnabled) return + + // Пропускаем первый рендер (начальная загрузка баланса) + if (prevBalanceRef.current === null) { + prevBalanceRef.current = balance + return + } + + if (prevBalanceRef.current !== balance) { + prevBalanceRef.current = balance + + if (typeof window !== 'undefined' && sessionStorage.getItem('low-balance-offer-shown') === 'true') { + return + } + + invokeLowBalanceOffer(); + } + }, [balance, me, modal, isFewTokensModalEnabled]) + + const invokeLowBalanceOffer = (notEnoughtTokens: boolean = false) => { + const tokensPerPlan = Number(me.payment_plan?.plan?.tokens_per_plan) + const isCorporate = me.payment_plan?.plan?.is_corporate + const isFreePlan = tokensPerPlan <= 10 + const threshold = tokensPerPlan * 0.15 + const freePlanThreshold = tokensPerPlan * 0.3 + + if (isCorporate) { + return + } + + if (!isFreePlan && balance < threshold) { + modal.setState(true, { offerUid: me.payment_plan?.plan?.uid, me, notEnoughtTokens }) + sessionStorage.setItem('low-balance-offer-shown', 'true') + return + } + + if (isFreePlan && balance < freePlanThreshold) { + modal.setState(true, { offerIndex: 1, notEnoughtTokens }) + sessionStorage.setItem('low-balance-offer-shown', 'true') + return + } + } + + return +} @@ -0,0 +1,3 @@ +export { LowBalanceOfferPlate } from './ui/low-balance-offer-plate' +export { LowBalanceOfferOnStart } from './ui/low-balance-offer-on-start' +export { LowBalanceOfferTrigger } from './ui/low-balance-offer-trigger' @@ -2,3 +2,6 @@ export const PLATE_CHANGE_PASSWORD = 'plate-change-password' export const RESEND_INVATION_PASSWORD = 'resend-invation-password' export const ERROR_REPORT = 'error-report' export const BUSINESS_ERROR_REPORT = 'business-error-report' +export const LOW_BALANCE_OFFER = 'low-balance-offer' +export const SUBSCRIPTION_CHANGE_NOTIFICATION = 'subscription-change-notification' +export const PLATE_ADD_VOICE = 'plate-add-voice' @@ -13,11 +13,12 @@ function makeModalInstance(set: Set, get: Get, key: string) { store: {}, setState(state, store) { const modals = get().modals; + const currentModal = modals[key]; - store = store ?? {}; + const newStore = store !== undefined ? store : (currentModal?.store ?? {}); set(() => ({ - modals: { ...modals, [this.id]: { ...modals[key], state, store } }, + modals: { ...modals, [this.id]: { ...modals[key], state, store: newStore } }, })); }, setStoreProperty(key, value) { @@ -3,7 +3,7 @@ width: 100%; max-width: calc(100dvw); height: 100dvh; - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.7); top: 0; left: 0; transition: all 300ms ease-in-out; @@ -66,7 +66,7 @@ &__content { background-color: var(--new-ui-main-color); - border-radius: 20px; + border-radius: 16px; position: relative; overflow: hidden; height: fit-content; @@ -20,6 +20,8 @@ export interface PlatesTemplateProps { animationClass?: string animationBehaviorClass?: string headerClassName?: string + /** Перекрывает фон контента плашки (например `#151518`) */ + contentBackgroundColor?: string variant?: Variant } @@ -35,6 +37,7 @@ export default function PlatesTemplate({ animationBehaviorClass = styles['template__animation-behavior'], closeModal = () => {}, headerClassName, + contentBackgroundColor, variant = 'primary', }: PlatesTemplateProps) { const { setModal, getModal } = usePlatesStore() @@ -64,6 +67,7 @@ export default function PlatesTemplate({ modal.state ? '' : animationBehaviorClass, styles[`template__content_${variant}`] )} + style={contentBackgroundColor ? { backgroundColor: contentBackgroundColor } : undefined} onClick={(e) => e.stopPropagation()} > ) : ( - - + + )} - - + + Нет аккаунта? + Зарегистрироваться @@ -321,38 +207,29 @@ const Login: NextPageWithLayout = () => { {desktop && ( - + {''} {''} + + + Творчество. Технологии. Ты. + + + {`Создавай уникальный контент и общайся с продвинутыми чат-ботами,\n используя современные нейросети.`} + + )} @@ -1,11 +1,139 @@ +.container { + overflow: hidden; + padding: 0; + background-color: #151518; + + @media screen and (max-width: 1000px) { + margin: 0 auto; + } +} + +.form { + width: 50%; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: 1000px) { + width: 100%; + align-items: flex-start; + margin-top: 10px; + } +} + +.formInner { + width: 40vh; + height: 80%; + display: flex; + justify-content: center; + align-items: center; + + @media screen and (max-width: 1000px) { + margin-top: 16px; + } +} + +.logoMobile { + margin-top: 20px; +} + +.title { + color: #e1e1e1; + line-height: 36.4px; + font-size: 26px; + font-weight: bold; + margin-bottom: 20px; +} + +.orEmailWrapper { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + width: 100%; +} + +.orEmailLine { + height: 1px; + flex: 1; + background-color: #44444a; +} + +.orEmail { + flex: 1; + font-size: 15px; + color: #a4aab5; + display: flex; + justify-content: center; + align-items: center; +} + +.successText { + text-align: center; + color: #e1e1e1; +} + .imagebox { + width: 50%; + height: 100vh; + background-color: #8280ff; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + @media screen and (max-width: 1000px) { display: none; } } -.form{ - @media screen and (max-width: 1000px) { - width: 100%; +.imageboxLogo { + position: absolute; + top: 35px; + left: 35px; +} + +.dashboardImage { + position: absolute; + transform: scale(1.15); + bottom: 60px; + transform-origin: center; +} + +.imageboxLogoContent { + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.imageboxLogoText { + font-weight: 800; + font-size: 48px; + color: #ffffff; +} + +.imageboxLogoSubText { + font-weight: 400; + font-style: Regular; + line-height: 24px; + font-size: 20px; + text-align: center; + white-space: pre-line; +} + +.inputField { + margin-bottom: 16px; + + :global(.MuiInputBase-input) { + padding-block: 10px; + + &::placeholder { + color: #a4aab5; + opacity: 1; + } } } \ No newline at end of file @@ -17,11 +17,12 @@ import { getDeviceType } from '#/shared/lib/helpers' import styles from './register.module.scss' const Register: NextPageWithLayout = () => { - const device = getDeviceType() - - const desktop = device === 'desktop' - + const [desktop, setDesktop] = useState(true) const [success, setSuccess] = useState(false) + + React.useEffect(() => { + setDesktop(getDeviceType() === 'desktop') + }, []) const [isReferral, setIsReferral] = useState(true) const referral = useAppSelector((state) => state.user.referral) @@ -45,49 +46,25 @@ const Register: NextPageWithLayout = () => { return ( {!desktop && ( {''} )} - - + + {success ? ( - - + + Вы успешно зарегистрированы.
Мы отправили письмо для верификации на ваш email. В случае отсутствия, проверьте @@ -100,66 +77,49 @@ const Register: NextPageWithLayout = () => {
) : ( - - Регистрация - + Регистрация {referral === '' && isReferral && ( <> - - или email - + +
+ или email +
+ )} - + )} {desktop && ( - - {''} - {''} - - )} + + {''} + {''} + + + Творчество. Технологии. Ты. + + + {`Создавай уникальный контент и общайся с продвинутыми чат-ботами,\n используя современные нейросети.`} + + + + )} ) } @@ -1,62 +0,0 @@ -import { useState } from 'react' -import { Box, Button, Typography } from '@mui/material' - -import { Modal } from '#/shared' - -import styles from './subscription.module.scss' - -export const CancelSubscriptionButton = () => { - const [isModalOpen, setIsModalOpen] = useState(false) - - const handleOpenModal = () => { - setIsModalOpen(true) - } - - const handleCloseModal = () => { - setIsModalOpen(false) - } - - const handleCancelSubscription = () => { - // Здесь будет логика отмены подписки - handleCloseModal() - } - - return ( - <> - - - - - - - Отмена подписки - - - - Внимание! Вы отменяете подписку на тариф 10 000 токенов. Следующее списание будет отменено, вы можете продолжать пользоваться возможностями тарифа до истечения его срока. - - - - - - - - - ) -} \ No newline at end of file @@ -1,6 +1,7 @@ import { Box, LinearProgress, linearProgressClasses, Stack, styled, Typography } from '@mui/material' import { commaSeparated } from '#/shared' +import { formatDate } from '#/shared/lib/helpers/date-helper' import { formatPlanPrice } from '#/shared/lib/helpers/format-plan-price' import { declineToken } from '#/shared/lib/helpers/get-token' @@ -23,10 +24,30 @@ interface CurrentPlanAndBalanceProps { planTokenLimit: string planPrice: string isIndividual: boolean + isRecurring: boolean + nextPaymentAt?: string | null } -export const CurrentPlanAndBalance = ({ balance, planTokenLimit, planPrice, isIndividual }: CurrentPlanAndBalanceProps) => { +function formatNextPaymentLabel(iso: string | null | undefined): string | null { + if (!iso?.trim()) return null + const d = new Date(iso) + if (Number.isNaN(d.getTime())) return null + return formatDate(d, { day: '2-digit', month: '2-digit', year: 'numeric' }) +} + +export const CurrentPlanAndBalance = ({ + balance, + planTokenLimit, + planPrice, + isIndividual, + isRecurring, + nextPaymentAt, +}: CurrentPlanAndBalanceProps) => { const formatterdPlanPrice = formatPlanPrice(planPrice, isIndividual) + const nextPaymentFormatted = formatNextPaymentLabel(nextPaymentAt) + const nextPaymentLabel = nextPaymentFormatted + ? `${isRecurring ? 'Следующее списание' : 'Дата окончания'}: ${nextPaymentFormatted}` + : 'Без даты окончания' const planTokenLimitNumber = Number(planTokenLimit) const percentage = planTokenLimitNumber > 0 ? Math.min(100, Math.max(0, (balance / planTokenLimitNumber) * 100)) : 0 @@ -45,6 +66,9 @@ export const CurrentPlanAndBalance = ({ balance, planTokenLimit, planPrice, isIn {formatterdPlanPrice} + + {nextPaymentLabel} + @@ -18,7 +18,7 @@ export const FeaturesSection = ({ currentTokenLimitUID, onCurrentTokenLimitUIDCh return ( - + Возможности с подпиской +
+ + + +
+

+ Загрузите примеры голоса
для клонирования +

+

+ аудиофайлы в формате .mp3 или{' '} + .wav, не более{' '} + 10MB каждый +

+ {showMicInvite ? ( + <> +

e.stopPropagation()}> + или +

+ + Записать аудио с микрофона + + + ) : null} + {showMicPanel ? ( +
e.stopPropagation()}> + {micPanelPhase === MicPanelPhase.LoadingList ? ( +

Загрузка списка микрофонов…

+ ) : null} + {micPanelPhase === MicPanelPhase.NoMicrophones ? ( +

Нет доступных микрофонов. Проверьте разрешения браузера.

+ ) : null} + {showMicDeviceRow ? ( +
+ + void onMicStartStop(ev)} + > + {micPanelPhase === MicPanelPhase.Recording ? 'Остановить' : 'Начать'} + +
+ ) : null} +
+ ) : null} +
+ ) : ( +
+ setVoiceTitle(e.target.value)} + /> +
+ + + Ваш браузер не поддерживает аудио. + +
+
+ + Попробовать ещё + + void onTrain()} + > + Обучить > + +
+
+ )} +
+ + ) +} @@ -0,0 +1 @@ +export { AddVoicePlate } from './add-voice-plate' @@ -0,0 +1,3 @@ +.card { + background: linear-gradient(to top left, rgba(42, 40, 188, 0.2), transparent 52%), #1a1a1e; +} @@ -0,0 +1,27 @@ +import * as React from 'react' +import Image from 'next/image' + +import shell from './voice-clone-card-shell.module.scss' +import fig from './voice-clone-figure.module.scss' +import styles from './add-voices-card.module.scss' + +export function AddVoicesCard() { + return ( +
+

+ Добавляйте новые голоса +

+
+
+ Карточка добавления голоса +
+
+
+ ) +} @@ -0,0 +1,96 @@ +.grid { + display: grid; + grid-template-columns: 203px 78px 405px; + grid-template-rows: auto auto; + row-gap: 16px; + align-items: start; +} + +.cellFigure1 { + grid-column: 1; + grid-row: 1; + align-self: center; +} + +.cellArrow { + grid-column: 2; + grid-row: 1; + place-self: center; +} + +.cellFigure2 { + grid-column: 3; + grid-row: 1; + align-self: center; +} + +.cellText1 { + grid-column: 1; + grid-row: 2; + min-width: 0; +} + +.cellText2 { + grid-column: 3; + grid-row: 2; + min-width: 0; +} + +.stepFigure { + margin: 0; + border-radius: 12px; + overflow: hidden; + line-height: 0; + background: rgba(0, 0, 0, 0.2); +} + +.stepFigureNarrow { + width: 203px; + height: 146px; +} + +.stepFigureWide { + width: 405px; + height: 148px; +} + +.stepImage { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.arrowWrap { + display: flex; + align-items: center; + justify-content: center; + color: #a4aab5; + + svg { + display: block; + } +} + +.stepTitle { + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; + color: #fff; +} + +.stepText { + margin: 8px 0 0; + max-width: 220px; + font-size: 14px; + font-weight: 500; + line-height: 1.35; + letter-spacing: -0.01em; + color: #a4aab5; +} + +.cellText2 .stepText { + max-width: 405px; +} @@ -0,0 +1,71 @@ +import * as React from 'react' +import Image from 'next/image' + +import shell from './voice-clone-card-shell.module.scss' +import styles from './how-to-model-card.module.scss' + +function StepArrow() { + return ( + + + + + ) +} + +export function HowToModelCard() { + return ( +
+

+ Как работать с моделью +

+ +
+
+
+ Выбор шаблона голоса или добавление своего +
+ +
+ +
+ +
+ Ввод текста для озвучки и кнопка запуска генерации +
+ +
+

Шаг 1.

+

Выберите один из шаблонов голоса или создайте свой

+
+ +
+

Шаг 2.

+

+ Введите текст, который нужно озвучить и нажмите на кнопку – запустится процесс генерации +

+
+
+
+
+ ) +} @@ -0,0 +1,4 @@ +export { HowToModelCard } from './how-to-model-card' +export { SoundParamsCard } from './sound-params-card' +export { AddVoicesCard } from './add-voices-card' +export { ReuseQueryCard } from './reuse-query-card' @@ -0,0 +1,3 @@ +.card { + background: linear-gradient(to top right, #2b2b42, #18181b); +} @@ -0,0 +1,27 @@ +import * as React from 'react' +import Image from 'next/image' + +import shell from './voice-clone-card-shell.module.scss' +import fig from './voice-clone-figure.module.scss' +import styles from './reuse-query-card.module.scss' + +export function ReuseQueryCard() { + return ( +
+

+ Переиспользуйте запрос по клику +

+
+
+ Раздел «Мои генерации» +
+
+
+ ) +} @@ -0,0 +1,3 @@ +.card { + background: linear-gradient(to bottom right, #2b2b42, #18181b); +} @@ -0,0 +1,30 @@ +import * as React from 'react' +import Image from 'next/image' + +import shell from './voice-clone-card-shell.module.scss' +import fig from './voice-clone-figure.module.scss' +import styles from './sound-params-card.module.scss' + +export function SoundParamsCard() { + return ( +
+

+ Меняйте параметры звучания +

+

+ Экспериментируйте с настройками, чтобы добиться идеального звучания +

+
+
+ Панель параметров +
+
+
+ ) +} @@ -0,0 +1,79 @@ +$purple-fade: rgba(42, 40, 188, 0.9) 0%, rgba(42, 40, 188, 0.4) 32%, rgba(42, 40, 188, 0.1) 55%, transparent 72%; + +.shell { + position: relative; + overflow: hidden; + border-radius: 16px; + padding: 25px; + width: 100%; + background: #1d1d21; +} + +.shellFill { + height: 100%; + display: flex; + flex-direction: column; +} + +.shellFill .body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.shellCornerGlow::before, +.shellCornerGlow::after { + content: ''; + position: absolute; + z-index: 0; + pointer-events: none; + width: min(100%, 520px); + height: 75%; + min-height: 200px; + filter: blur(48px); + opacity: 0.72; +} + +.shellCornerGlow::before { + top: -12%; + right: -8%; + background: radial-gradient(ellipse 80% 70% at 100% 0%, $purple-fade); +} + +.shellCornerGlow::after { + bottom: -12%; + left: -8%; + background: radial-gradient(ellipse 80% 70% at 0% 100%, $purple-fade); +} + +.title { + position: relative; + z-index: 1; + margin: 0 0 20px; + font-size: 28px; + font-weight: 600; + line-height: 1; + letter-spacing: -0.01em; + color: #fff; +} + +.shell:has(.description) .title { + margin-bottom: 12px; +} + +.description { + position: relative; + z-index: 1; + margin: 0 0 20px; + font-size: 14px; + font-weight: 500; + line-height: 1.35; + letter-spacing: -0.01em; + color: #a4aab5; +} + +.body { + position: relative; + z-index: 1; +} @@ -0,0 +1,17 @@ +.figure { + margin: 0; + border-radius: 12px; + overflow: hidden; + line-height: 0; + background: rgba(0, 0, 0, 0.2); +} + +.figureFull { + max-width: 100%; +} + +.image { + display: block; + width: 100%; + height: auto; +} @@ -0,0 +1,3 @@ +export { default as VoiceClonePage } from './voice-clone-page' +export { VoiceCloneCardsGrid } from './voice-clone-cards-grid' +export { VoiceCloneMobile } from './voice-clone-mobile' @@ -0,0 +1,50 @@ +.root { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + max-width: 1130px; + overflow-x: auto; +} + +.row { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: 20px; + min-width: 0; +} + +.mainSlot { + flex: 1 1 0; + min-width: 0; + max-width: 736px; + display: flex; +} + +.sideSlot { + flex: 0 1 374px; + width: 374px; + max-width: 100%; + min-width: 280px; + display: flex; +} + +.rowBottom .mainSlot { + flex: 502 1 0; + max-width: none; +} + +.rowBottom .sideSlot { + flex: 608 1 0; + width: auto; + min-width: 0; +} + +.mainSlot > *, +.sideSlot > * { + flex: 1; + width: 100%; + min-width: 0; + min-height: 0; +} @@ -0,0 +1,28 @@ +import * as React from 'react' + +import { AddVoicesCard, HowToModelCard, ReuseQueryCard, SoundParamsCard } from './cards' + +import styles from './voice-clone-cards-grid.module.scss' + +export function VoiceCloneCardsGrid() { + return ( +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ ) +} @@ -0,0 +1,58 @@ +.root { + width: 100%; +} + +.title { + margin: 0 0 25px 0; + font-weight: 700; + font-size: 18.27px; + line-height: 100%; + letter-spacing: -0.01em; + color: #fff; +} + +.steps { + display: flex; + flex-direction: column; + gap: 25px; + margin-top: 25px; +} + +.step { + position: relative; +} + +.stepBadge { + position: absolute; + z-index: 1; + top: -8px; + left: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + height: 29px; + padding: 7px 10px; + border-radius: 95px; + font-weight: 800; + font-size: 12.79px; + line-height: 1; + letter-spacing: -0.01em; + color: #fff; + background: #8280ff; + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25); +} + +.stepImageWrap { + margin: 0; + line-height: 0; +} + +.stepImage { + display: block; + height: auto; + width: 100%; +} + +.footerInputWrap { + margin-top: 28px; +} @@ -0,0 +1,58 @@ +import * as React from 'react' +import { Box } from '@mui/material' +import Image from 'next/image' + +import styles from './voice-clone-mobile.module.scss' +import { ModelInput } from '#/features/model-input' + +export function VoiceCloneMobile() { + const steps = [ + { label: 'Шаг 1', src: '/voice-clone/message-input-mobile.png', alt: 'Ввод сообщения' }, + { label: 'Шаг 2', src: '/voice-clone/voice-selector-mobile.png', alt: 'Выбор голоса' }, + { label: 'Шаг 3', src: '/voice-clone/message-preview-mobile.png', alt: 'Предпросмотр сообщения' }, + ] + const inputTypes = [{ type: 'text', required: true, versions: [] }] + + return ( + + +
+

+ Добавляйте свой голос +
+ или выбирайте из пресетов +

+ +
+ {steps.map((step) => ( +
+
{step.label}
+
+ {step.alt} +
+
+ ))} +
+ +
+ false} + styles='chats' + viewMobileSettings={() => {}} + currentVersion='' + input_types={inputTypes} + /> +
+
+
+
+ ) +} @@ -0,0 +1,33 @@ +import * as React from 'react' +import { Box, Typography } from '@mui/material' + +import { NextPageWithLayout } from '#/pages/_app' +import { getDeviceType } from '#/shared/lib/helpers' + +import { VoiceCloneCardsGrid } from './voice-clone-cards-grid' +import { VoiceCloneMobile } from './voice-clone-mobile' + +export const VoiceClonePage: NextPageWithLayout = () => { + const device = getDeviceType() + const desktop = device === 'desktop' + + return ( + + {desktop && ( + <> + + Войсклон + + + + + + + )} + + {!desktop && } + + ) +} + +export default VoiceClonePage @@ -0,0 +1 @@ +export { VoiceCloneSelect } from './voice-clone-select' @@ -0,0 +1,61 @@ +.subheader { + background-color: #151518; + color: #a4aab5; + font-weight: 600; + font-size: 12px; + line-height: 32px; +} + +.hiddenAudio { + display: none; +} + +/* min-height синхронизирован с VOICE_SELECT_ROW_HEIGHT в voice-clone-select.tsx */ +.menuItemRow { + display: flex; + align-items: center; + gap: 12px; + min-height: 58px; + padding-block: 0; + background-color: transparent; + overflow: hidden; +} + +.menuItemRowAlt { + background-color: #1d1d21; +} + +/* ширина/высота = VOICE_PREVIEW_PLAY_SIZE в voice-clone-select.tsx */ +.menuPlaySlot { + flex-shrink: 0; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; +} + +.rowActions { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0; + margin-left: 2px; +} + +.titleInput { + flex: 1; + min-width: 0; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid #5a5a5a; + background: #2a2a2a; + color: #e0e0e0; + font-size: 15px; + line-height: 1.2; + outline: none; + + &:focus { + border-color: #827fff; + } +} @@ -0,0 +1,481 @@ +import { Pause, PlayArrow } from '@mui/icons-material' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import { + Box, + IconButton, + ListSubheader, + MenuItem, + Select, + SelectChangeEvent, + Stack, + Typography, +} from '@mui/material' +import type { MenuProps } from '@mui/material/Menu' +import Image from 'next/image' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { deleteMediaVoice, MediaVoiceItem, patchMediaVoiceTitle } from '#/entities/audio-model' +import { baseColor } from '#/shared/lib/constants/colors' +import { c } from '#/shared/lib/helpers' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { TooltipCustom } from '#/shared' + +import styles from './voice-clone-select.module.scss' + +const VOICE_TITLE_DISPLAY_MAX = 15 + +function formatVoiceSelectLabel(full: string): { text: string; tooltip?: string } { + if (full.length <= VOICE_TITLE_DISPLAY_MAX) return { text: full } + return { text: `${full.slice(0, VOICE_TITLE_DISPLAY_MAX)}…`, tooltip: full } +} + +function VoiceSelectLabel({ full }: { full: string }) { + const { text, tooltip } = formatVoiceSelectLabel(full) + const typo = ( + + {text} + + ) + if (!tooltip) return typo + return ( + + {typo} + + ) +} + +/** Иконки из /public (как в api-keys, account) */ +const VOICE_ICON_EDIT = '/svg/account/pencil.svg' +const VOICE_ICON_SAVE = '/svg/tic.svg' +const VOICE_ICON_DELETE = '/svg/main_menu/trash.svg' + +/** Высота поля селекта и строк меню (px) */ +const VOICE_SELECT_ROW_HEIGHT = 58 + +/** Круглая кнопка превью (px), без класса плеера 42px */ +const VOICE_PREVIEW_PLAY_SIZE = 26 + +const previewPlayIconButtonSx = { + flexShrink: 0, + width: VOICE_PREVIEW_PLAY_SIZE, + height: VOICE_PREVIEW_PLAY_SIZE, + minWidth: VOICE_PREVIEW_PLAY_SIZE, + padding: 0, + borderRadius: '50%', + backgroundColor: baseColor, + color: '#fff', + '&:hover': { + backgroundColor: '#6d6be0', + }, +} as const + +const previewPlayIconSx = { fontSize: 14 } as const + +const voiceActionIconButtonSx = { + flexShrink: 0, + width: 32, + height: 32, + minWidth: 32, + padding: 0, + borderRadius: '8px', + color: '#8280ff', + '&:hover': { + backgroundColor: 'rgba(130, 128, 255, 0.12)', + }, + '&.Mui-disabled': { + opacity: 0.45, + }, +} as const + +const selectSx = { + boxShadow: 'none', + borderRadius: '13px', + backgroundColor: '#4B4B4B', + border: '2px solid #40404E;', + color: '#A6A5A5', + '.MuiOutlinedInput-root': { + minHeight: VOICE_SELECT_ROW_HEIGHT, + height: VOICE_SELECT_ROW_HEIGHT, + }, + '.MuiSelect-select': { + display: 'flex', + alignItems: 'center', + minHeight: VOICE_SELECT_ROW_HEIGHT, + height: '100%', + paddingTop: 0, + paddingBottom: 0, + boxSizing: 'border-box', + }, + '.MuiSelect-icon': { + color: '#A6A5A5', + }, + '&& fieldset': { + border: '0px solid transparent', + }, + '&.Mui-focused': { + border: '2px solid #40404E;', + borderColor: baseColor, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + }, + '&:hover': { + '&& fieldset': { + border: '0px solid transparent', + }, + }, +} as const + +/** Как в Autocomplete listbox / прочих меню: ограничение высоты + overflow; скроллбар — globals `.smallScroll` (см. voice-cloning-model, image-model). */ +const VOICE_SELECT_MENU_LIST_MAX_HEIGHT = 'min(45vh, 400px)' + +const VOICE_SELECT_MENU_PROPS: Partial = { + transitionDuration: 0, + PaperProps: { + sx: { + backgroundColor: '#151518', + borderRadius: '8px', + marginTop: '8px', + boxShadow: '0px 0px 4px rgba(0, 0, 0, 0.04), 0px 4px 32px rgba(0, 0, 0, 0.16)', + }, + }, + MenuListProps: { + className: 'smallScroll', + sx: { + color: '#A6A5A5', + backgroundColor: '#151518', + maxHeight: VOICE_SELECT_MENU_LIST_MAX_HEIGHT, + overflowY: 'auto', + scrollbarGutter: 'stable', + '& .MuiMenuItem-root': { + minHeight: VOICE_SELECT_ROW_HEIGHT, + }, + }, + }, +} + +type Props = { + voices: MediaVoiceItem[] + presets: MediaVoiceItem[] + value: string + loading: boolean + onSelect: (item: MediaVoiceItem) => void + /** Токен для PATCH/DELETE голосов; без него кнопки не показываются */ + accessToken?: string + onVoiceListChanged?: () => void + /** После удаления — сбросить выбранный uid, если удалён он */ + onDeletedVoice?: (uid: string) => void +} + +export function VoiceCloneSelect({ + voices, + presets, + value, + loading, + onSelect, + accessToken, + onVoiceListChanged, + onDeletedVoice, +}: Props) { + const { showMessage } = useShowDataStore() + const all = [...voices, ...presets] + const audioRef = useRef(null) + const [playingUid, setPlayingUid] = useState(null) + const [editingUid, setEditingUid] = useState(null) + const [editDraft, setEditDraft] = useState('') + const [mutatingUid, setMutatingUid] = useState(null) + + const stopPreview = useCallback(() => { + const el = audioRef.current + if (el) { + el.pause() + } + setPlayingUid(null) + }, []) + + const togglePreview = useCallback( + (item: MediaVoiceItem) => { + const el = audioRef.current + if (!el || !item.file?.trim()) return + + if (playingUid === item.uid) { + el.pause() + setPlayingUid(null) + return + } + + el.pause() + el.src = item.file + el.currentTime = 0 + const p = el.play() + if (p !== undefined) { + p.then(() => setPlayingUid(item.uid)).catch(() => setPlayingUid(null)) + } + }, + [playingUid] + ) + + useEffect(() => { + const el = audioRef.current + if (!el) return + const onEnded = () => setPlayingUid(null) + el.addEventListener('ended', onEnded) + return () => { + el.removeEventListener('ended', onEnded) + } + }, []) + + useEffect( + () => () => { + audioRef.current?.pause() + }, + [] + ) + + const onChange = (e: SelectChangeEvent) => { + const uid = e.target.value + const item = all.find((v) => v.uid === uid) ?? null + if (item) { + onSelect(item) + } + } + + const closeMenuExtras = useCallback(() => { + stopPreview() + setEditingUid(null) + setEditDraft('') + }, [stopPreview]) + + const commitRename = useCallback( + async (item: MediaVoiceItem) => { + if (!accessToken) return + const trimmed = editDraft.trim() + if (!trimmed) { + showMessage('Введите название') + return + } + setMutatingUid(item.uid) + try { + const res = await patchMediaVoiceTitle(item.uid, trimmed, accessToken) + if (res.status >= 400) { + showMessage('Не удалось изменить название') + return + } + setEditingUid(null) + setEditDraft('') + onVoiceListChanged?.() + showMessage('Название изменено', 'success') + } finally { + setMutatingUid(null) + } + }, + [accessToken, editDraft, onVoiceListChanged, showMessage] + ) + + const removeVoice = useCallback( + async (item: MediaVoiceItem) => { + if (!accessToken) return + if (!window.confirm('Удалить этот голос?')) return + setMutatingUid(item.uid) + try { + const res = await deleteMediaVoice(item.uid, accessToken) + if (res.status >= 400) { + showMessage('Не удалось удалить голос') + return + } + if (editingUid === item.uid) { + setEditingUid(null) + setEditDraft('') + } + onDeletedVoice?.(item.uid) + onVoiceListChanged?.() + showMessage('Голос удалён', 'success') + } finally { + setMutatingUid(null) + } + }, + [accessToken, editingUid, onDeletedVoice, onVoiceListChanged, showMessage] + ) + + const flatDisabled = !loading && all.length === 0 + + const renderVoiceMenuItem = (item: MediaVoiceItem, optionIndex: number, isUserVoice: boolean) => { + const hasPreview = Boolean(item.file?.trim()) + const isEditing = editingUid === item.uid + const busy = mutatingUid === item.uid + const showActions = isUserVoice && Boolean(accessToken) + + return ( + + {hasPreview ? ( + { + e.stopPropagation() + togglePreview(item) + }} + onMouseDown={(e) => e.stopPropagation()} + aria-label={playingUid === item.uid ? 'Пауза' : 'Прослушать пример'} + > + {playingUid === item.uid ? : } + + ) : ( + + )} + {isEditing ? ( + setEditDraft(e.target.value)} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + void commitRename(item) + } + }} + aria-label='Название голоса' + autoFocus + /> + ) : ( + + + + )} + {showActions ? ( + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + if (isEditing) { + void commitRename(item) + } else { + setEditingUid(item.uid) + setEditDraft(item.title) + } + }} + aria-label={isEditing ? 'Сохранить' : 'Изменить'} + > + {isEditing ? ( + + ) : ( + + )} + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + void removeVoice(item) + }} + aria-label='Удалить голос' + > + + + + ) : null} + + ) + } + + return ( + + + ) +} @@ -0,0 +1,2 @@ +export { default } from './voice-cloning-model' +export { default as VoiceCloningModelPage } from './voice-cloning-model' @@ -0,0 +1,181 @@ +.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; + } +} + +.staticTabsWrapper { + display: flex; + align-items: center; + width: calc(24.5% + 2px); + margin-left: auto; + margin-right: calc(3%); + margin-top: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 22px; + margin: 14px 0; + + > *:first-child { + min-width: 200px; + } + + @media screen and (max-width: 768px) { + align-items: flex-start; + width: 100%; + max-width: 100%; + gap: 4px; + overflow-x: hidden; + } +} +@keyframes fadein { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.icon { +} + + +.tags { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + @media screen and (max-width: 768px) { + overflow-x: auto; + overflow-y: hidden; + width: 100%; + min-width: 0; + max-width: 100%; + align-self: stretch; + -webkit-overflow-scrolling: touch; + } +} + +.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) { + width: 150px; + height: 40px; + padding: 12px; + transition: none; + flex-shrink: 0; + + .tag__text { + opacity: 1; + position: static; + display: block; + } + } + + &__icon { + color: var(--air-color); + } + + &:hover { + padding: 12px; + width: 200px; + + @media screen and (max-width: 768px) { + width: 150px; + + .tag__icon { + animation: none; + } + + .tag__text { + animation: none; + } + } + + @media screen and (min-width: 769px) { + .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,653 @@ +import { useCallback, 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 './voice-cloning-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 { usePredictPrice } from '#/features/predict-price/model/use-predict-price' +import Title from '#/features/title/title' +import { getVoicesAndPresets, MediaVoiceItem } from '#/entities/audio-model' +import { MessageSend } from '#/entities/message' +import { NextPageWithLayout } from '#/pages/_app' +import { DrawerCustom, Loader } from '#/shared' +import { c, getDeviceType, getOs } from '#/shared/lib/helpers' +import { useProgressLoader } from '#/shared/lib/hooks/use-progress-loader' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { SvgIcon } from '#/shared/ui/svg' +import ProgressLoader from '#/widgets/loaders/progress-loader-props' +import { AudioMessagesList } from '#/widgets/messages/ui/audio-messages-list' +import { ModelApiView, StaticTabs } from '#/widgets/model-api-view' +import { useDeviceType } from '#/shared/lib/hooks' + +import { AddVoiceCta } from '../add-voice-cta' +import { AddVoicePlate } from '../add-voice-plate' +import { VoiceCloneCardsGrid, VoiceCloneMobile } from '../voice-clone-empty-state' +import { VoiceCloneSelect } from '../voice-clone-select' + +const VoiceCloningModelPage: NextPageWithLayout = () => { + const { query, push } = useRouter() + const { data: session } = useSession() + const { showMessage } = useShowDataStore() + + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useDeviceType(deviceType, deviceOs) + + const [scope, setScope] = useState<'playground' | 'api'>('playground') + const [prompt, setPrompt] = useState('') + const [selectedVoiceUid, setSelectedVoiceUid] = useState('') + const [voices, setVoices] = useState([]) + const [presets, setPresets] = useState([]) + const [voicesLoading, setVoicesLoading] = useState(false) + + 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, 'voice') + const { createImage, isComplete, createLoading } = useCreateMediaMessage( + showMessage, + modelType, + 'voice', + deviceType, + setMessages, + mobileScrollContainer + ) + + const createWithVoice = useCallback( + (dataForSend: MessageSend) => { + const baseInfo = + typeof dataForSend.info === 'object' && dataForSend.info !== null + ? { ...(dataForSend.info as Record) } + : {} + + const voiceOrPreset = presets.some((p) => p.uid === selectedVoiceUid) + ? { preset_id: selectedVoiceUid } + : { voice_id: selectedVoiceUid } + + return createImage({ + ...dataForSend, + ...baseInfo, ...voiceOrPreset + }) + }, + [createImage, selectedVoiceUid, presets] + ) + + const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput(version, includeParams, createWithVoice) + + const onVoiceSelect = useCallback((item: MediaVoiceItem) => { + setSelectedVoiceUid(item.uid) + }, []) + + const refetchVoicesAndPresets = useCallback(async () => { + if (!session?.access) return + setVoicesLoading(true) + try { + const { voices: v, presets: p } = await getVoicesAndPresets(session.access) + setVoices(v) + setPresets(p) + } finally { + setVoicesLoading(false) + } + }, [session?.access]) + + const allVoiceOptions = useMemo(() => [...voices, ...presets], [voices, presets]) + + useEffect(() => { + if (voicesLoading || allVoiceOptions.length === 0) return + const valid = Boolean(selectedVoiceUid && allVoiceOptions.some((v) => v.uid === selectedVoiceUid)) + if (!valid) { + onVoiceSelect(allVoiceOptions[0]) + } + }, [voicesLoading, allVoiceOptions, selectedVoiceUid, onVoiceSelect]) + + const { progress, isVisible: isProgressVisible } = useProgressLoader({ + isLoading: createLoading, + duration: 150000, // 150 секунд для создания аудио + }) + + 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]) + + useEffect(() => { + if (!session?.access) return + let cancelled = false + setVoicesLoading(true) + getVoicesAndPresets(session.access) + .then(({ voices: v, presets: p }) => { + if (!cancelled) { + setVoices(v) + setPresets(p) + } + }) + .finally(() => { + if (!cancelled) setVoicesLoading(false) + }) + return () => { + cancelled = true + } + }, [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]) + + const predictPriceInfo = useMemo( + () => ({ ...(includeParams || {}), ...(version ? { version } : {}) }), + [includeParams, version] + ) + + const predictedPrice = usePredictPrice({ + modelSlug: modelType, + content: prompt, + fileExists: !!image, + info: predictPriceInfo, + token: session?.access, + enabled: !!modelType && !!session?.access && scope === 'playground', + }) + const hasGenerations = (messages?.length ?? 0) > 0 + const mobileScrollAreaHeight = `calc(100dvh - 116px - 61px - 15px ${botParams?.blocked ? '- 17px' : '- 23.5px'})` + const compactPlaygroundEmpty = scope === 'playground' && !hasGenerations + /** Меню + отступы layout + шапка страницы + табы — чтобы основной блок доходил до низа экрана без серой полосы */ + const mobileVoiceCompactMinHeight = 'calc(100dvh - 200px)' + + return ( + <> + + {botParams ? botParams?.title : 'Загрузка...'} + +
+ + <div className={styles.tags}> + {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> + {desktop && ( + <Stack className={styles.staticTabsWrapper}> + <StaticTabs scope={scope} setScope={setScope} /> + </Stack>)} + </div> + + {!desktop && ( + <Stack sx={{ display: 'flex', alignItems: 'center', width: '100%', marginTop: '10px' }}> + <StaticTabs scope={scope} setScope={setScope} /> + </Stack> + )} + + <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', + ...(!desktop && compactPlaygroundEmpty + ? { minHeight: mobileVoiceCompactMinHeight, justifyContent: 'flex-start' } + : {}), + }} + > + <Box + sx={{ + width: desktop ? '73%' : '100%', + marginLeft: 0, + display: 'flex', + flexDirection: desktop ? 'column' : 'column-reverse', + ...(!desktop && compactPlaygroundEmpty ? { flex: 1, minHeight: 0, alignSelf: 'stretch' } : {}), + }} + > + {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)} + predictedPrice={predictedPrice} + /> + )} + <Box sx={{ width: '100%', marginTop: '10px' }}> + {isProgressVisible && ( + <ProgressLoader progress={progress} height={15} title='Создание аудио...' showPercentage={true} /> + )} + </Box> + {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='voice' + modelParams={filteredParams} + showFileExample={showFileExample} + /> + </Box> + <Box sx={{ display: scope === 'playground' ? 'block' : 'none' }}> + {hasGenerations ? ( + <AudioMessagesList + device={deviceType} + audios={messages} + getMessagesPagination={fetchMessages} + onPromptClick={(content) => setPrompt(content)} + /> + ) : ( + <VoiceCloneCardsGrid /> + )} + </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', + ...(compactPlaygroundEmpty + ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } + : {}), + }} + > + <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', + ...(compactPlaygroundEmpty + ? { flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 } + : {}), + }} + > + <Box + sx={{ + padding: compactPlaygroundEmpty ? '30px 30px 12px' : '30px', + ...(compactPlaygroundEmpty + ? { + flex: 1, + minHeight: 0, + overflowY: 'auto', + } + : { + height: mobileScrollAreaHeight, + 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> + {hasGenerations ? ( + <AudioMessagesList + device={deviceType} + audios={messages} + getMessagesPagination={fetchMessages} + onPromptClick={(content) => setPrompt(content)} + /> + ) : ( + <VoiceCloneMobile /> + )} + </Box> + + <Box sx={{ display: scope === 'api' ? 'block' : 'none' }}> + <ModelApiView + version={version || ''} + slug={botParams?.slug || ''} + APIModel='voice' + modelParams={filteredParams} + showFileExample={showFileExample} + /> + </Box> + </Box> + + <Stack + alignItems='center' + sx={{ + display: 'flex', + zIndex: 10, + position: 'relative', + margin: 1.25, + marginTop: 'auto', + }} + > + {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)} + predictedPrice={predictedPrice} + /> + )} + {isProgressVisible && ( + <ProgressLoader progress={progress} height={15} title='Создание аудио...' showPercentage={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', + display: desktop ? 'flex' : 'none', + }} + > + {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} + /> + </> + ) : ( + <></> + )} + <AddVoiceCta disabled={!session?.access} /> + <VoiceCloneSelect + voices={voices} + presets={presets} + value={selectedVoiceUid} + loading={voicesLoading} + onSelect={onVoiceSelect} + accessToken={session?.access} + onVoiceListChanged={refetchVoicesAndPresets} + onDeletedVoice={(uid) => { + if (selectedVoiceUid === uid) setSelectedVoiceUid('') + }} + /> + {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} + /> + </> + ) : ( + <></> + )} + <Box sx={{ marginTop: '12px' }}> + <AddVoiceCta disabled={!session?.access} /> + <VoiceCloneSelect + voices={voices} + presets={presets} + value={selectedVoiceUid} + loading={voicesLoading} + onSelect={onVoiceSelect} + accessToken={session?.access} + onVoiceListChanged={refetchVoicesAndPresets} + onDeletedVoice={(uid) => { + if (selectedVoiceUid === uid) setSelectedVoiceUid('') + }} + /> + </Box> + {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> + <AddVoicePlate onSuccess={refetchVoicesAndPresets} /> + </> + ) +} + +export default VoiceCloningModelPage @@ -0,0 +1,2 @@ +export { default } from './voice-cloning-models' +export { VoiceCloningModelsPage } from './voice-cloning-models' @@ -0,0 +1,31 @@ +.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; + } +} + @@ -0,0 +1,72 @@ +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 { getVoiceCloneModels } from '#/entities/audio-model' +import { AudioModelCard, IShortModel } from '#/entities/model-entity' +import { NextPageWithLayout } from '#/pages/_app' + +import styles from './voice-cloning-models.module.scss' +import { getDeviceType, getOs } from '#/shared/lib/helpers' +import { useDeviceType } from '#/shared/lib/hooks' + +export const VoiceCloningModelsPage: NextPageWithLayout = () => { + const [models, setModels] = useState<IShortModel[] | null>(null) + const deviceType = getDeviceType() + const deviceOs = getOs() + const { desktop } = useDeviceType(deviceType, deviceOs) + + const { data, status } = useSession() + const payment_plan = useAppSelector((state) => state.user.payment_plan) + + useEffect(() => { + if (!data) return + + getVoiceCloneModels(data.access).then((res) => setModels(res)).catch(() => { + setModels([]) + }) + }, [status]) + + return ( + <> + <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} + modelsBasePath='voice-cloning' + 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> + </> + ) +} + +export default VoiceCloningModelsPage @@ -0,0 +1,2 @@ +export { default as VoiceCloningModelsPage } from './voice-cloning-models' +export { default as VoiceCloningModelPage } from './voice-cloning-model' @@ -0,0 +1 @@ +export * from './ui' @@ -21,7 +21,7 @@ import { getPersons } from '#/widgets/business-persons/api/get-persons' import ArrowUpOrDown from '#/widgets/top-bar-model/ui/arrow-up-or-down' import { translateEmailStatus, formatDateStatus, formatEmail } from '../../lib/lib' -import { getModalById, PLATE_CHANGE_PASSWORD, RESEND_INVATION_PASSWORD } from '#/features/modals' +import { getModalById, PLATE_CHANGE_PASSWORD } from '#/features/modals' import { ResponseGetBusinessGroups } from '#/features/invite-person-in-business/model/types' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { resendInvation } from '#/features/resend-invation-corp/api/resend-invation' @@ -43,7 +43,6 @@ export const PersonsList = ({ const { data: session } = useSession() const passChangeModal = getModalById(PLATE_CHANGE_PASSWORD) - const passResendModal = getModalById(RESEND_INVATION_PASSWORD) useEffect(() => { setPersons(personsList) @@ -79,24 +78,32 @@ export const PersonsList = ({ }) ) setCurrentPerson(null) - showMessage(`Лимит пользователя ${email} успешно изменён!`, 'success') + showMessage(`Данные сотрудника ${email} изменены!`, 'success') } const handleOpenResendModal = async (email: string) => { - const response = await resendInvation(email, session?.access) - - if (response.status == 200) { - showMessage('Приглашение переотправлено!', 'success') - setPersons((prev) => - prev?.map((el) => { - if (el.email === email) { - return { ...el, acceptance_status: 'pending' as const } - } - return el - }) ?? null - ) - } else { - showMessage(response.data) + try { + const response = await resendInvation(email, session?.access) + + if (response.status == 200) { + showMessage('Приглашение переотправлено!', 'success') + setPersons((prev) => + prev?.map((el) => { + if (el.email === email) { + return { ...el, acceptance_status: 'pending' as const } + } + return el + }) ?? null + ) + } else { + // Проверяем тип response.data и преобразуем в строку + const errorMessage = typeof response.data === 'string' + ? response.data + : response.data?.detail || 'Ошибка при отправке приглашения' + showMessage(errorMessage, 'error') + } + } catch (error) { + showMessage('Произошла ошибка при отправке приглашения', 'error') } } @@ -250,4 +257,4 @@ export const PersonsList = ({ )} </Box> ) -} +} \ No newline at end of file @@ -38,11 +38,12 @@ import { Search } from '#/shared' // import styles from '#/widgets/business-models/ui/models-list/models-list.module.scss' import { getBusinessGroups } from '#/widgets/business-persons/api/get-businessGroups' import ArrowUpOrDown from '#/widgets/top-bar-model/ui/arrow-up-or-down' -import { getModalById, PLATE_CHANGE_PASSWORD, RESEND_INVATION_PASSWORD } from '#/features/modals' +import { getModalById, PLATE_CHANGE_PASSWORD } from '#/features/modals' import { formatDateStatus, formatEmail, translateEmailStatus } from '../../lib/lib' import { XMark } from '#/features/remove-person' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { resendInvation } from '#/features/resend-invation-corp/api/resend-invation' export const SecurityList = ({ securityList, @@ -59,7 +60,6 @@ export const SecurityList = ({ const [currentPerson, setCurrentPerson] = useState<ResponseGetPersons | null>(null) const passChangeModal = getModalById(PLATE_CHANGE_PASSWORD) - const passResendModal = getModalById(RESEND_INVATION_PASSWORD) useEffect(() => { setPersons(securityList) @@ -91,12 +91,40 @@ export const SecurityList = ({ el.token_limit = limit return el } - return el }) ) setCurrentPerson(null) - showMessage(`Лимит пользователя ${email} успешно изменён!`, 'success') + showMessage(`Данные сотрудника ${email} изменены!`, 'success') + } + + const handleOpenResendModal = async (email: string) => { + try { + const response = await resendInvation(email, data?.access) + + if (response.status == 200) { + showMessage('Приглашение переотправлено!', 'success') + setPersons((prev) => + prev?.map((el) => { + if (el.email === email) { + return { ...el, acceptance_status: 'pending' as const } + } + return el + }) ?? null + ) + } else { + const errorMessage = typeof response.data === 'string' + ? response.data + : response.data?.detail || 'Ошибка при отправке приглашения' + showMessage(errorMessage, 'error') + } + } catch (error) { + showMessage('Произошла ошибка при отправке приглашения', 'error') + } + } + + const handleChangePassword = (email: string) => { + passChangeModal.setState(true, { email }) } useEffect(() => { @@ -179,13 +207,7 @@ export const SecurityList = ({ {statusPerson === 'Приглашен' ? ( <button className={styles.refreshStatusButton} - onClick={() => { - passResendModal.setState(true, { - callback: () => { - console.log('Смена пароля') - }, - }) - }} + onClick={() => handleOpenResendModal(person.email)} > <Image src={'/svg/refresh-outline.svg'} @@ -200,13 +222,7 @@ export const SecurityList = ({ <TableCell>{Math.floor(+person.token_limit)}</TableCell> <TableCell className={styles.change}> <button - onClick={() => { - passChangeModal.setState(true, { - callback: () => { - console.log('Смена пароля') - }, - }) - }} + onClick={() => handleChangePassword(person.email)} > Сменить пароль </button> @@ -352,4 +368,4 @@ export const InviteSecurityModal: FC<InviteModalProps> = ({ open, onClose, showN </form> </Modal> ) -} +} \ No newline at end of file @@ -113,8 +113,7 @@ function Chat<T>({ paddingTop: 0, paddingBottom: 1.25, width: '100%', - height: desktop ? '75vh' : `calc(100dvh - ${tags.length == 0 ? '200px' : '240px'})`, - overflow: 'hidden', + height: desktop ? '75vh' : `calc(100dvh - ${tags.length == 0 ? '200px' : '285px'})`, overflow: 'hidden', }} > <ChatMessagesList @@ -80,6 +80,10 @@ transform: translateX(-50%); z-index: 1000; pointer-events: auto; + /* мост по hover: между кнопкой и вертикальным слайдером иначе pointer «просвечивает» и срабатывает mouseleave */ + padding-bottom: 52px; + margin-bottom: -52px; + box-sizing: content-box; } .volumeSlider { @@ -275,7 +275,11 @@ const AudioPlayerBase = ( </Box> {device === 'desktop' && ( - <Box className={styles.volumeContainer} onMouseEnter={() => setVolumeHovered(true)}> + <Box + className={styles.volumeContainer} + onMouseEnter={() => setVolumeHovered(true)} + onMouseLeave={() => setVolumeHovered(false)} + > <IconButton onClick={toggleMute} className={styles.volumeButton} @@ -286,7 +290,7 @@ const AudioPlayerBase = ( </IconButton> {volumeHovered && ( - <Box onMouseEnter={() => setVolumeHovered(true)} className={styles.volumeSliderWrapper}> + <Box className={styles.volumeSliderWrapper}> <Slider size='small' orientation='vertical' @@ -7,10 +7,10 @@ export interface IScope { export interface IComponentProps { currentVersion: string modelSlug: string - modelsType: 'text' | 'image' | 'video' | 'audio' + modelsType: 'text' | 'image' | 'video' | 'audio' | 'voice' showFileExample: boolean modelParams: Record<string, string | number | number[] | boolean> } export type TScope = 'chat-bots' | 'api' -export type TApiModel = 'text' | 'image' | 'video' | 'audio' +export type TApiModel = 'text' | 'image' | 'video' | 'audio' | 'voice' @@ -8,7 +8,7 @@ interface IProps { scopeTitle: string currentVersion: string modelSlug: string - modelsType: 'text' | 'image' | 'video' | 'audio' + modelsType: 'text' | 'image' | 'video' | 'audio' | 'voice' showFileExample: boolean modelParams: Record<string, string | number | number[] | boolean> } @@ -63,6 +63,12 @@ export const menuListMiddle = [ icon: '/svg/side-menu/audio', activeList: ['audio'], }, + { + title: 'Клонирование голоса', + link: '/voice-cloning', + icon: '/svg/side-menu/audio', + activeList: ['voice-cloning'], + }, ] const drawerWidth = 240 @@ -123,11 +129,7 @@ export const SideMenu = ({}) => { const business_error_report = getModalById(BUSINESS_ERROR_REPORT) const match = useMediaQuery('(min-height:800px)') - const open = useMemo(() => getOptionValue('sidemenu', { sidemenu_state: 'opened' }).sidemenu_state, [settings]) - - useEffect(() => { - if (isNextStepVisible) updateSettings('sidemenu', { sidemenu_state: 'closed' }) - }, [isNextStepVisible, updateSettings]) + const open = useMemo(() => getOptionValue('sidemenu', { sidemenu_state: 'closed' }).sidemenu_state, [settings]) const filteredMenuListTop = useMemo(() => { return menuListTop.filter((item) => {