@@ -1,3 +0,0 @@ -{ - "typescript.tsdk": "node_modules/typescript/lib" -} Binary files a/public/images/dashboard.png and /dev/null differ @@ -1,3 +1,3 @@ { - "main-page": "AIR" + "main-page": "AIR" } @@ -1,3 +1,3 @@ { - "main-page": "AIR" + "main-page": "AIR" } @@ -1,3 +1,5 @@ - - + + \ No newline at end of file @@ -1,5 +1,5 @@ - - - + + + @@ -1,18 +1,19 @@ { - "name": "AIR", - "short_name": "AIR", - "icons": [ - { - "src": "/logo192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/logo512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "start_url": "/", - "display": "standalone" -} + "name": "AIR", + "short_name": "AIR", + "icons": [ + { + "src": "/logo192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/logo512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "start_url": "/", + "display": "standalone" + } + \ No newline at end of file @@ -17,6 +17,8 @@ const acceptTypes: any = { image: 'image/png,image/jpeg', zip: 'application/zip', pdf: 'application/pdf', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + doc: 'application/msword', audio: 'audio/*', txt: '.txt', text: '', @@ -59,14 +61,7 @@ export const LoadImage = ({ loading, unpinImage, image, imageLoad, types }: IPro } return ( <> - + { - const link = useMemo(() => { - return accessed_models && !accessed_models.includes(slug) ? '/account?scope=subscribe' : `chat-bot/${slug}` - }, [accessed_models]) - - const { theme } = useThemeAndDevice() - - return ( - - - - - - - {title} - {text} - - - {blocked ? ( -
- - - Модель недоступна - -
- ) : ( - accessed_models && - !accessed_models.includes(slug) && ( -
- - Недоступно в текущем тарифе -
- ) - )} - - - ) -} - -export default ChatCard @@ -1,4 +1,4 @@ -import React, { FC, SyntheticEvent } from 'react' +import React, { FC, SyntheticEvent, useEffect } from 'react' import { Divider, InputAdornment, TextField } from '@mui/material' import Stack from '@mui/material/Stack' import { TextFieldProps } from '@mui/material/TextField/TextField' @@ -9,6 +9,7 @@ import { SendBtn } from '#/app/components/input_components/send_button' import { useAppSelector } from '#/app/store/store' import { IModelInputs } from '#/shared/api/models/models' import { styleInputWithoutBorderFocus } from '#/shared/ui/input' +import { useThemeAndDevice } from '#/shared/lib/hooks' interface Input { loading: boolean @@ -26,6 +27,7 @@ interface Input { // setImage: React.Dispatch> styles: 'images' | 'chats' | 'audio' input_types?: IModelInputs[] + blocked?: boolean viewMobileSettings: () => void currentVersion: string resendValue?: string @@ -43,6 +45,7 @@ export const UniqInput: FC = ({ viewMobileSettings, currentVersion, resendValue, + blocked, }: Input) => { const theme = useAppSelector((state) => state.theme.theme) const [disabled, setDisabled] = React.useState(true) @@ -50,6 +53,10 @@ export const UniqInput: FC = ({ const [types, setTypes] = React.useState([]) const [typeVersions, setTypeVersions] = React.useState({}) const [value, setValue] = React.useState('') + + useEffect(() => { + console.log(loading) + }, [loading]) // const [disableOnChange, setDisableOnChange] = React.useState(false) React.useEffect(() => { @@ -90,7 +97,7 @@ export const UniqInput: FC = ({ = ({ borderRadius: '15px', ...styleInputWithoutBorderFocus, '& label': { color: '#8853FA' }, - backgroundColor: theme === 'light' ? 'white' : styles === 'images' ? '#151518' : 'transparent', + backgroundColor: + theme === 'light' + ? 'white' + : styles === 'images' + ? '#151518' + : 'transparent', textarea: { color: theme === 'light' ? '#272727' : '#E1E1E1', '::-webkit-scrollbar': { @@ -119,14 +131,19 @@ export const UniqInput: FC = ({ }, }, fieldset: { - borderRadius: '15px', - border: theme === 'light' ? '2px solid #E1E1E1' : '2px solid #303035', + borderRadius: '15px', + border: + theme === 'light' + ? '2px solidrgb(242, 242, 242)' + : '2px solid #303035', }, '& ::placeholder': { fontSize: '16px', + color: theme === 'light' ? '#bbbbbb !important' : 'white !important', }, '& .Mui-disabled': { - webkitTextFillColor: theme === 'light' ? '#bbbbbb !important' : '#646464 !important', + WebkitTextFillColor: + theme === 'light' ? '#bbbbbb !important' : '#7d7d7d !important', fontSize: '16px', letterSpacing: '0.05px', fontWeight: '600', @@ -144,7 +161,7 @@ export const UniqInput: FC = ({ } }} // onPaste={(e:ClipboardEvent) => handlePaste(e)} - value={disabled ? 'Ввод текста недоступен для этой модели' : value} + value={disabled || blocked ? 'Ввод текста недоступен для этой модели' : value} onChange={(e) => { setValue(e.target.value) }} @@ -161,24 +178,29 @@ export const UniqInput: FC = ({ (typeVersions['image'].length === 0 || typeVersions['image'].includes(currentVersion)) && ( <> - + {!blocked && ( + + )} )} - {!disabled && ( + {!disabled && !blocked && ( = ({ device }) => { - { - //@ts-ignore - handleClick2(e) - }} - > - - Не отображается баланс или нейросети? - - + dispatch(change(null))} @@ -1,13 +1,5 @@ - - + - - - - \ No newline at end of file @@ -0,0 +1,5 @@ + + + \ No newline at end of file @@ -0,0 +1 @@ +export * from './message.routes' \ No newline at end of file @@ -0,0 +1,26 @@ +import { API_URL } from '#/shared/lib/constants' +import { IMessageRequest } from '#/shared/lib/types/types-gpt' +import axios, { AxiosResponse } from 'axios' +import { Message, MessageSend } from '../types' + +export async function sendImage(model: string | null, dataForSend: MessageSend | FormData, token?: string) { + const HeaderDataType = dataForSend instanceof FormData ? 'multipart/form-data' : 'application/json' + + return await axios.post(API_URL + `/media/image/${model}`, dataForSend, { + withCredentials: true, + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': HeaderDataType, + }, + }) +} + +export async function getImagesBySlug(slug: string, token: string, offset?: number, limit = 10) { + return await axios.get(API_URL + `/media/image/${slug}?limit=${limit}&offset=${offset}`, { + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} @@ -0,0 +1 @@ +export * from './message' \ No newline at end of file @@ -0,0 +1,18 @@ +export interface MessageSend { + content: string + file?: File | null + info: T +} + +export interface Message { + content: string + created_at: string + elapsed_time: string + file: T + from_model: boolean + info: null + is_favourite: boolean + is_sent: boolean + uid: string + model: string +} @@ -0,0 +1,2 @@ +export * from './types' +export * from './api' \ No newline at end of file @@ -7,15 +7,23 @@ export interface IShortModel { slug: string image: string blocked: boolean + tags: IModelTag[] actual_stat: { generation_time: string tokens_cost: string } } +export interface IModelTag { + title: string + icon: string + color: string +} + export interface IModel { uid: string title: string + blocked: boolean description: string slug: string image: string @@ -23,6 +31,7 @@ export interface IModel { parameters: IModelParams[] versions: IModelVersions[] inputs: IModelInputs[] + tags: IModelTag[] } export interface IModelParams { name: string @@ -1,52 +1,129 @@ +.tag { + display: flex; + align-items: center; + justify-content: center; + background-color: white; + border-radius: 100px; + // padding-right: 20px; + width: 50px; + height: 50px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.1); + transition: width 0.3s ease-in-out; + + &__icon { + color: var(--air-color); + } + + &:hover { + width: 60%; + padding: 12px; + // height: unset; + + .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; + } +} + .card { background-color: var(--new-ui-main-color); - width: 381px; - height: 227px; border-radius: 15px; - margin-right: 20px; - margin-top: 20px; + position: relative; + min-height: 280px; cursor: pointer; - padding: 30px; box-sizing: border-box; + overflow: hidden; + height: 100%; @media (max-width: 420px) { width: 92vw; } + &__tags { + position: absolute; + top: 20px; + right: 20px; + width: calc(100% - 40px); + display: flex; + justify-content: flex-end; + gap: 8px; + } + + &__content { + display: flex; + flex-direction: column; + justify-content: space-between; + height: calc(100% - 160px); + } + + &__image { + width: 100%; + height: 160px; + object-fit: cover; + } + .description { - margin-top: 20px; + padding: 20px; + padding-top: 15px; .title { font-weight: 600; + font-size: 20px; + margin-bottom: 8px; } .text { color: var(--new-ui-gray-color); font-weight: 400; - margin-top: 6px; font-size: 15px; } } } +@keyframes fadein { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + .blocked { display: flex; align-items: center; justify-content: center; - position: absolute; - top: 20px; - right: 20px; - background-color: white; - border-radius: 100px; - padding-right: 20px; - padding: 8px 12px; + background-color: #c32528; + // padding-right: 20px; + padding: 12px 15px; + gap: 5px; - span { - color: #ff2372; - font-weight: 500; - padding-left: 10px; + &__text { + color: white; font-size: 14px; + font-weight: 600; } } @@ -0,0 +1,88 @@ +import React, { useEffect, useMemo } from 'react' +import { Avatar, Box, Button, Typography } from '@mui/material' +import Image from 'next/image' +import Link from 'next/link' +import LockSvg from '#/assets/svg/lock.svg?react' +import BlockedSvg from '#/assets/svg/blocked.svg?react' + +import styles from './card.module.scss' +import { useThemeAndDevice } from '#/shared/lib/hooks' +import { c } from '#/shared/lib/helpers' +import { IShortModel } from '#/entities/model-entity' +import { SvgIcon } from '#/shared/ui/svg' + +export interface ModelCardProps extends IShortModel { + accessed_models: string[] | null +} + +export function ChatModelCard({ + description, + image, + title, + slug, + accessed_models, + blocked, + tags, +}: ModelCardProps) { + const link = useMemo(() => { + return accessed_models && !accessed_models.includes(slug) + ? '/account?scope=subscribe' + : `chat-bot/${slug}` + }, [accessed_models]) + + const { theme } = useThemeAndDevice() + + return ( + +
+
+ {title} +
+
+
+

{title}

+

{description}

+
+ + {blocked ? ( +
+ + + Модель недоступна + +
+ ) : ( + accessed_models && + !accessed_models.includes(slug) && ( +
+ + Недоступно в текущем тарифе +
+ ) + )} +
+ +
+ {tags && + tags.map((tag, index) => ( +
+ + {tag.title} +
+ ))} +
+
+ + ) +} @@ -0,0 +1,88 @@ +import React, { useEffect, useMemo } from 'react' +import { Avatar, Box, Button, Typography } from '@mui/material' +import Image from 'next/image' +import Link from 'next/link' +import LockSvg from '#/assets/svg/lock.svg?react' +import BlockedSvg from '#/assets/svg/blocked.svg?react' + +import styles from './card.module.scss' +import { useThemeAndDevice } from '#/shared/lib/hooks' +import { c } from '#/shared/lib/helpers' +import { IShortModel } from '#/entities/model-entity' +import { SvgIcon } from '#/shared/ui/svg' + +export interface ModelCardProps extends IShortModel { + accessed_models: string[] | null +} + +export function ImageModelCard({ + description, + image, + title, + slug, + accessed_models, + blocked, + tags, +}: ModelCardProps) { + const link = useMemo(() => { + return accessed_models && !accessed_models.includes(slug) + ? '/account?scope=subscribe' + : `images/${slug}` + }, [accessed_models]) + + const { theme } = useThemeAndDevice() + + return ( + +
+
+ {title} +
+
+
+

{title}

+

{description}

+
+ + {blocked ? ( +
+ + + Модель недоступна + +
+ ) : ( + accessed_models && + !accessed_models.includes(slug) && ( +
+ + Недоступно в текущем тарифе +
+ ) + )} +
+ +
+ {tags && + tags.map((tag, index) => ( +
+ + {tag.title} +
+ ))} +
+
+ + ) +} @@ -0,0 +1,2 @@ +export * from './chat-model-card' +export * from './image-model-card' \ No newline at end of file @@ -1,3 +1,4 @@ export * from './api' export * from './model' -export * from './types' \ No newline at end of file +export * from './types' +export * from './ui' \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-image-bot-create' \ No newline at end of file @@ -0,0 +1,76 @@ +import { getUserBalance } from '#/entities/balance' +import { Message, MessageSend, sendImage } from '#/entities/message' +import { useAppDispatch } from '#/app/store/store' +import { Device } from '#/shared/lib/types/entities' +import { formDataHelper } from '#/widgets/messages' +import { useSession } from 'next-auth/react' +import { Dispatch, RefObject, SetStateAction, useEffect, useState } from 'react' + +export function useImageBotCreateImage( + showError: (message: string) => void, + type: string, + device: Device, + setMessages: Dispatch>, + mobileScrollContainer: RefObject +) { + const [isComplete, setIsComplete] = useState(false) + + const [createLoading, setCreateLoading] = useState(false) + + const { data } = useSession() + + const dispatch = useAppDispatch() + + const createImage = async (dataForSend: MessageSend) => { + const { content, file } = dataForSend + + setIsComplete(false) + setCreateLoading(true) + + const dataSending = file ? formDataHelper(file, dataForSend) : dataForSend + + const { data: messages } = await sendImage(type, dataSending, data?.access) + + setCreateLoading(false) + + if (typeof messages === 'string') { + showError(messages) + return + } + + dispatch(getUserBalance(data?.access)) + + setMessages((prev: Message[]) => { + if (!prev || !prev.length) return messages + + if (device === 'desktop') { + return [...messages, ...prev] + } + return [...prev, ...messages] + }) + + setIsComplete(true) + + if (device === 'desktop') { + return window.scrollTo({ + top: 0, + behavior: 'smooth', + }) + } + + setTimeout(() => { + if (!mobileScrollContainer.current) return + + mobileScrollContainer.current.scroll({ + top: mobileScrollContainer.current.scrollHeight, + behavior: 'smooth', + }) + }, 500) + } + + return { + createImage, + isComplete, + createLoading, + } +} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-images-bot-filters' \ No newline at end of file @@ -0,0 +1,16 @@ +import { useAppSelector } from '#/app/store/store' +import { useState } from 'react' + +export function useImagesBotFilters() { + const [openFiltersMobile, setOpenFiltersMobile] = useState(false) + const [params, setParams] = useState(false) + const includeParams = useAppSelector((state) => state.params.params) + + return { + openFiltersMobile, + setOpenFiltersMobile, + params, + setParams, + includeParams, + } +} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-images-uniq-input' \ No newline at end of file @@ -0,0 +1,64 @@ +import { useShowData } from '#/shared' +import { MessageSend } from '#/shared/lib/types/model' +import { useState, ChangeEvent } from 'react' + +export function useImagesUniqInput( + version: string, + includeParams: object, + createImage: (dataForSend: MessageSend) => any +) { + const [image, setImage] = useState(null) + + const { error, showError } = useShowData() + + function onLoadImage(event: ChangeEvent) { + if (event.target.files) { + setImage(event.target.files[0]) + } + } + + function onCreateImage(input: string, required: (string | null)[]) { + // про switch не слышали люди)) + if (required.includes('text') && (input === '' || input === null)) { + showError('Введите сообщение!') + return false + } + if (required.includes('image') && image === null) { + showError('Прикрепите изображение!') + return false + } + if (required.includes('zip') && image === null) { + showError('Прикрепите архив!') + return false + } + + // Снова какой то пиз**ц + let data = {} + if (version === '') { + data = { + ...includeParams, + } + } else { + data = { + version: version, + ...includeParams, + } + } + + createImage({ + content: input, + file: image, + info: { + ...data, + }, + }) + return true + } + + return { + image, + setImage, + onLoadImage, + onCreateImage, + } +} @@ -0,0 +1 @@ +export * from './model' \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-images-bot-pagination' \ No newline at end of file @@ -0,0 +1,148 @@ +import { useAppSelector } from '#/app/store/store' +import { getImagesBySlug, Message } from '#/entities/message' +import { useShowData } from '#/shared' +import { Device } from '#/shared/lib/types/entities' +import { getImagesGalery } from '#/widgets/messages' +import { useMediaQuery } from '@mui/material' +import { useSession } from 'next-auth/react' +import { useEffect, useRef, useState } from 'react' +import { LimitSize, Limit } from '../types' +import { useRouter } from 'next/router' + +export function useImageBotPagination(deviceType: Device) { + const refScrollMobile = useRef(null) + const refScrollDesktop = useRef(null) + const mobileScrollContainer = useRef(null) + const offset = useRef(0) + const observer = useRef(null) + + const [isHidden, setIsHidden] = useState(false) + + const { query } = useRouter() + + const [messages, setMessages] = useState([]) + + const [loading, setLoading] = useState(true) + + const { showError } = useShowData() + + const { data } = useSession() + + const limits: Record = { + small: { + active: useMediaQuery('(max-height: 600px)'), + limit: 20, + firstLimit: 30, + }, + medium: { + active: useMediaQuery('(min-height: 600px) and (max-height: 900px)'), + limit: 30, + firstLimit: 50, + }, + large: { + active: useMediaQuery('(min-height: 900px)'), + limit: 45, + firstLimit: 70, + }, + } + + const fetchMessages = async (count?: number) => { + if (!data) return + + setLoading(true) + + const { data: answer, ...response } = await getImagesBySlug( + query.slug as string, + data.access, + offset.current, + count || 10 + ) + + if (response.status >= 400 || !Array.isArray(answer)) + return showError('Ошибка загрузки чата') + + if (deviceType === 'desktop') { + setMessages((prev) => [...prev, ...answer]) + } else { + setMessages((prev) => [...answer.reverse(), ...prev]) + } + + offset.current += answer.length + } + + const callback = async function (entries: IntersectionObserverEntry[]) { + if (!entries[0].isIntersecting) return + + if (isHidden) return setIsHidden(false) + + if (deviceType === 'desktop') { + const active = Object.values(limits).find((item) => item.active) + + if (!active) return + + if (offset.current > 0) { + fetchMessages(active.limit) + } else { + fetchMessages(active?.firstLimit) + } + } + + const { current } = mobileScrollContainer + + if (!current) return + + const scrollBottom = current.scrollHeight - current.scrollTop + + await fetchMessages() + + setTimeout(() => { + const { current } = mobileScrollContainer + + if (!current) return + + if (offset.current === 0) current.scrollTop = current.scrollHeight - scrollBottom + else { + current.scroll({ + top: current.scrollHeight - scrollBottom, + behavior: 'smooth', + }) + } + + setLoading(false) + }, 500) + } + + function onObserverMounted() { + const currentObserver = + deviceType === 'desktop' ? refScrollDesktop.current : refScrollMobile.current + + if (!currentObserver) return + + observer.current = new IntersectionObserver(callback, { rootMargin: '400px' }) + + observer.current.observe(currentObserver!) + } + + function isHiddenHandler() { + if (document.visibilityState === 'hidden') return setIsHidden(true) + setIsHidden(false) + } + + useEffect(() => { + document.addEventListener('visibilitychange', isHiddenHandler) + return () => document.removeEventListener('visibilitychange', isHiddenHandler) + }, []) + + return { + refScrollMobile, + refScrollDesktop, + onObserverMounted, + messages, + loading, + setLoading, + offset, + setMessages, + fetchMessages, + mobileScrollContainer, + } +} @@ -0,0 +1 @@ +export * from './limits' \ No newline at end of file @@ -0,0 +1,7 @@ +export type LimitSize = 'small' | 'medium' | 'large' + +export interface Limit { + active: boolean + limit: number + firstLimit: number +} \ No newline at end of file @@ -0,0 +1,2 @@ +export * from './model' +export * from './types' \ No newline at end of file @@ -1,14 +1,12 @@ import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react' import { ImageWithState } from './types' -import { Message } from '#/shared/lib/types/model' +import { Message } from '#/entities/message' export const useImagesLibrary = ( - images: string[], + setModal: Dispatch>, current: string | null, - reverse: boolean, - setModal: Dispatch> + images: Message[] ) => { - const [imagesWithState, setImagesWithState] = useState(getImagesWithState(images)) const [initialCount, setInitialCount] = useState(0) const esc = (e: KeyboardEvent) => { @@ -18,54 +16,19 @@ export const useImagesLibrary = ( } } - function getImagesWithState(images: string[]) { - const imagesWithState = images.map((image) => ({ - image, - state: false, - })) - - return imagesWithState - } - - function updateImageState(image: string, state: boolean) { - setImagesWithState((prev) => prev.map((item) => (item.image === image ? { ...item, state } : item))) - } - - const currentImageIndex = useMemo( - () => (!current ? undefined : imagesWithState.findIndex((image) => image.image === current)), - [imagesWithState, current] + const currentIndex = useMemo( + () => images.findIndex((item) => item.file === current), + [current] ) - useEffect(() => { - if (initialCount === images.length) { - return - } - - setInitialCount(images.length) - - const filteredMessages = images.filter((item) => - !imagesWithState.find((i) => i.image === item) ? true : false - ) - - if (filteredMessages.length === images.length) { - return setImagesWithState(getImagesWithState(filteredMessages)) - } - - if (reverse) setImagesWithState([...imagesWithState, ...getImagesWithState(filteredMessages)]) - else setImagesWithState([...getImagesWithState(filteredMessages), ...imagesWithState]) - }, [images]) - useEffect(() => { window.addEventListener('keydown', esc) return () => window.removeEventListener('keydown', esc) }, []) return { - imagesWithState, - currentImageIndex, initialCount, setInitialCount, - setImagesWithState, - updateImageState, + currentIndex, } } @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react' +import React, { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react' import Image from 'next/image' import styles from './modal-styles.module.scss' @@ -10,8 +10,9 @@ import { Swiper, SwiperSlide } from 'swiper/react' import 'swiper/css' import { useLibrarySwiper } from '../model/use-swiper' import { ImageIcons, useImageIcons } from '#/widgets/messages' -import { Message } from '#/shared/lib/types/model' import { Typography } from '@mui/material' +import { ModalImage } from './modal-image' +import { Message } from '#/entities/message' interface IProps { modal: boolean @@ -20,32 +21,41 @@ interface IProps { setCurrent?: (value: string | null) => void onSlideFalse?: (...args: any) => any reverse?: boolean - images: Message[] + images: Message[] } -export default function FullScreenModal({ modal, setModal, current, images, onSlideFalse, reverse = false }: IProps) { - const filteredMessages = useMemo(() => images.map((message) => message.file as string), [images]) - - const { imagesWithState, updateImageState, currentImageIndex, initialCount } = useImagesLibrary( - filteredMessages, +export default function FullScreenModal({ + modal, + setModal, + current, + images, + onSlideFalse, + reverse = false, +}: IProps) { + const { initialCount, setInitialCount, currentIndex } = useImagesLibrary( + setModal, current, - reverse, - setModal + images ) + const count = useRef(0) + const { swiper, setSwiper, slideNext, slidePrev } = useLibrarySwiper(onSlideFalse, reverse) const { downloadFile } = useImageIcons() useEffect(() => { - if (!(initialCount < images.length && swiper)) return - - if (reverse) { - setTimeout(() => swiper.slideTo(initialCount, 1000), 1000) - return - } - - setTimeout(() => swiper.slideTo(images.length - initialCount - 1, 1000), 1000) + // debugger + if (reverse) + setTimeout(() => { + swiper?.slideNext() + count.current = images.length + }, 400) + else + setTimeout(() => { + swiper?.slideTo(images.length - count.current - 1) + count.current = images.length + }, 400) }, [images]) return ( @@ -119,7 +129,13 @@ export default function FullScreenModal({ modal, setModal, current, images, onSl
setModal(false)}> - + { + console.log(swiper?.activeIndex) + }} simulateTouch={false} - initialSlide={currentImageIndex} + initialSlide={currentIndex} onSwiper={(swiper) => setSwiper(swiper)} > - {imagesWithState.map(({ image, state }, index) => ( - - {!state && } -
- {!image.includes('.svg') ? ( - e.stopPropagation()} - onLoadingComplete={() => { - updateImageState(image, true) - }} - loading='lazy' - src={image} - width={'1500'} - height={'1500'} - alt='К сожалению, изображение не загрузилось' - /> - ) : ( - e.stopPropagation()} - onLoad={() => updateImageState(image, true)} - alt='К сожалению, изображение не загрузилось' - className={styles.image_style} - /> - )} -
+ {images.map((image, index) => ( + + ))} @@ -1 +1,2 @@ export { default as ImageModal } from './full-screen-modal' +export * from './modal-image' @@ -0,0 +1,23 @@ +.loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.image_style { + width: auto; + max-width: 70vw; + height: 100%; + position: relative; + user-select: none; + display: flex; + justify-content: center; + align-items: center; + img { + width: 100%; + height: 100%; + object-fit: contain; + user-select: none; + } +} @@ -0,0 +1,41 @@ +import { Message } from '#/entities/message' +import { Loader } from '#/shared' +import Image from 'next/image' +import React, { useEffect } from 'react' + +import styles from './modal-image.module.scss' + +interface ModalImageProps extends Message {} + +export const ModalImage = ({ file, ...image }: ModalImageProps) => { + const [state, updateImageState] = React.useState(false) + + return ( + <> + {!state && } +
+ {!file.includes('.svg') ? ( + e.stopPropagation()} + onLoadingComplete={() => { + updateImageState(true) + }} + loading='lazy' + src={file} + width={'1500'} + height={'1500'} + alt='К сожалению, изображение не загрузилось' + /> + ) : ( + e.stopPropagation()} + onLoad={() => updateImageState(true)} + alt='К сожалению, изображение не загрузилось' + className={styles.image_style} + /> + )} +
+ + ) +} @@ -63,29 +63,7 @@ height: fit-content; } -.loader { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} -.image_style { - width: auto; - max-width: 70vw; - height: 100%; - position: relative; - user-select: none; - display: flex; - justify-content: center; - align-items: center; - img { - width: 100%; - height: 100%; - object-fit: contain; - user-select: none; - } -} .slide { width: 80vw !important; @@ -1,4 +1,4 @@ -import React, { Dispatch, SetStateAction } from 'react' +import React, { Dispatch, SetStateAction, useMemo } from 'react' import Image from 'next/image' import styles from './modal-styles.module.scss' @@ -6,10 +6,13 @@ import styles from './modal-styles.module.scss' interface IProps { modal: boolean setModal: Dispatch> - image: string + image: string | null } export default function FullScreenModal({ modal, setModal, image }: IProps) { - const isSvg = image.includes('.svg') + const isSvg = useMemo(() => { + if (!image) return false + return image.includes('.svg') + }, []) return (
setModal(false)}> - + ) : ( К сожалению, изображение не загрузилось @@ -1,30 +1,30 @@ -.close_block { - position: absolute; - z-index: 105; - cursor: pointer; - right: 25px; - top: 25px; +.close_block{ + position: absolute; + z-index: 105; + cursor: pointer; + right: 25px; + top: 25px; } -.image_block { - position: absolute; - padding: 0 20px; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 105; - margin: auto; - width: fit-content; - height: fit-content; +.image_block{ + position: absolute; + padding: 0 20px; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 105; + margin: auto; + width: fit-content; + height: fit-content; } -.image_style { - position: relative; - width: 100%; - height: 100%; - max-width: 960px; - max-height: 700px; - border-radius: 15px; - object-fit: contain; -} +.image_style{ + position: relative; + width: 100%; + height: 100%; + max-width: 960px; + max-height: 700px; + border-radius: 15px; + object-fit: contain; +} \ No newline at end of file @@ -1,41 +1,14 @@ -.main { - width: 100%; - display: flex; - justify-content: flex-start; - gap: 20px; - margin-bottom: 24px; - - .chatWindow { - width: 70%; - } - - .settings { - width: 25%; - } - - @media (max-width: 768px) { - width: 100%; - display: flex; - - flex-direction: column-reverse; - - .chatWindow { - width: 100%; - } - - .settings { - width: 100%; - } - } -} .wrapModelTitle { margin-bottom: 20px; margin-top: 15px; + // width: max-content; + width: 100% !important; @media (max-width: 768px) { margin: 0; margin-top: 15px; } .bigTitle { + min-width: max-content !important; @media (max-width: 768px) { display: none; overflow: hidden; @@ -2,9 +2,17 @@ import React from 'react' import { Box, Typography } from '@mui/material' import Link from 'next/link' -import styles from '#/shared/styles/chats-bot-pages.module.scss' +import styles from './title.module.scss' -export default function Title(props: any) { +export interface TitleProps { + type: string + title: string + linkBack?: string + rightSlot?: React.ReactNode +} + +export default function Title(props: TitleProps) { + return ( @@ -12,11 +20,21 @@ export default function Title(props: any) { {' '} {props.type} •{' '} -  {props.title} + +  {props.title} + - - {props.title} - +
+ + {props.title} + +
+ {props.rightSlot} +
+
) } @@ -0,0 +1,258 @@ +import React from 'react' +import { Box, Card, CardMedia, Stack, TextField, Typography } from '@mui/material' +import Button from '@mui/material/Button' +import axios from 'axios' +import Head from 'next/head' +import Image from 'next/image' +import Link from 'next/link' +import { useRouter } from 'next/router' + +import { Layout } from '#/app/layout' +import { useAppSelector } from '#/app/store/store' +import { API_URL } from '#/shared/lib/constants/constants' +import { getRandomImage, getTypeDevice } from '#/shared/lib/helpers' +import { TDeviceProp } from '#/shared/lib/types/entities' + +export async function getServerSideProps(context: any): Promise<{ props: TDeviceProp }> { + const device = getTypeDevice(context) + + return { + props: { + device, + }, + } +} + +const ChangePassword: React.FC = ({ device }) => { + const [password1, setPassword1] = React.useState('') + + const [password2, setPassword2] = React.useState('') + + const [isChange, setIsChange] = React.useState(false) + + const { query, push } = useRouter() + + const changePassword = async () => { + let formData: any = new FormData() + + formData.append('password_1', password1) + formData.append('password_2', password2) + + if (password1.trim() && password2.trim()) { + try { + await axios.post(API_URL + `/auth/change-pass?token=${query.token}`, formData, { + headers: { + 'content-type': 'multipart/form-data ', + }, + }) + setIsChange(true) + setTimeout(() => push('/login'), 5000) + } catch (err) {} + } + } + + const desktop = device === 'desktop' + + const refImage = React.useRef(getRandomImage()) + + const theme = useAppSelector((state) => state.theme.theme) + + return ( + + + Восстановление аккаунта + + + + + + + {''} + + {desktop && ( + + + + + Midjourney + + + by honeynek + + + + + )} + {isChange ? ( + + + Ваш пароль успешно изменён! + + + Войти в аккаунт + + + ) : ( + + + Восстановление пароля + + + Новый пароль + + setPassword1(e.target.value)} + > + + Подтверждение пароля + + setPassword2(e.target.value)} + > + + + + + )} + + + ) +} + +export default ChangePassword @@ -1,8 +1,7 @@ import axios from 'axios' -import { IModel } from '#/shared/api/models/models' import { API_URL } from '#/shared/lib/constants' -import { IShortModel } from '#/entities/model-entity' +import { IShortModel, IModel } from '#/entities/model-entity' const model_api = { async getBots(token?: string): Promise { @@ -8,8 +8,8 @@ import { getUserBalance } from '#/entities/balance' import { useAppDispatch } from '#/app/store/store' import { API_URL } from '#/shared/lib/constants' import { Device } from '#/shared/lib/types/entities' -import { Message, MessageSend } from '#/shared/lib/types/model' import { IMessageRequest } from '#/shared/lib/types/types-gpt' +import { Message, MessageSend } from '#/entities/message' const formDataHelper = (file: File, dataForSend: MessageSend): FormData => { const FD = new FormData() @@ -120,23 +120,31 @@ export function useModel( return } + const date = new Date() + const userMessage: Message = { content: dataForSend.content.trim(), info: dataForSend.info as any, is_sent: true, - file: dataForSend.file ? ((await base64(dataForSend.file)) as string) : null, + model: '', + file: dataForSend.file + ? ((URL.createObjectURL(dataForSend.file) + + '?type=.' + + dataForSend.file.name.split('.')[1]) as string) + : null, from_model: false, uid: 'new-send', elapsed_time: '', is_favourite: false, - created_at: new Date().toISOString(), + created_at: date.toISOString(), } const modelMessageAboutStartGeneration: Message = { ...userMessage, content: `Ваш вопрос получен. Ожидание ответа от ${modelType}...`, from_model: true, - created_at: new Date().toISOString(), + file: null, + created_at: date.toISOString(), } setLoading(true) @@ -172,7 +180,7 @@ export function useModel( setMessages((prev) => [...prev!, userMessage]) try { //@ts-ignore - const message = (result.details as AxiosError).response!.data.trim() ?? 'Ошибка отправки сообщения' + const message = (result.details as AxiosError).response.data.trim() ?? 'Ошибка отправки сообщения' showError(message) return } catch (e) { @@ -186,7 +194,7 @@ export function useModel( } const deleteMessage = (message_uid: string) => { - axios.delete(process.env.NEXT_PUBLIC_MAIN_URL + `/chats/${currentChat}/messages/${message_uid}`, { + axios.delete(process.env.NEXT_PUBLIC_API_HOST + `/chats/${currentChat}/messages/${message_uid}`, { headers: { Authorization: `Bearer ${data?.access}`, }, @@ -449,6 +457,7 @@ export function useMedia(showError: (message: string) => void, modelType: string content: input, info: null, is_sent: true, + model: '', file: null, from_model: false, uid: 'new-send', @@ -1,70 +0,0 @@ -.card { - width: 381px; - overflow: hidden; - position: relative; - height: 290px; - border-radius: 15px; - background-color: var(--new-ui-main-color); - margin-top: 15px; - margin-right: 20px; - - @media (max-width: 420px) { - width: 92vw; - height: 300px; - } - - .description { - padding: 15px 20px; - - .title { - color: var(--new-ui-text-color); - font-weight: 600; - font-size: 21px; - } - .text { - margin-top: 15px; - font-size: 15px; - color: var(--new-ui-gray-color); - } - } -} - -.blocked { - display: flex; - align-items: center; - justify-content: center; - position: absolute; - top: 20px; - right: 20px; - background-color: white; - border-radius: 100px; - padding-right: 20px; - padding: 8px 12px; - - span { - color: #ff2372; - font-weight: 500; - padding-left: 10px; - font-size: 14px; - } -} -.locked { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 15px; - padding-right: 20px; - - span { - color: white; - padding-top: 10px; - font-weight: 500; - } -} @@ -1,72 +0,0 @@ -import React, { useMemo } from 'react' -import { Box, Button, Typography } from '@mui/material' -import Image from 'next/image' -import Link from 'next/link' -import LockSvg from '#/assets/svg/lock.svg?react' -import BlockedSvg from '#/assets/svg/blocked.svg?react' - -import styles from './card.module.scss' -import { useThemeAndDevice } from '../lib/hooks' - -interface IProps { - text: string - icon: string - title: string - slug: string - uid: string - blocked: boolean - accessed_models?: string[] | null -} - -export const Card = ({ text, icon, title, blocked, slug, accessed_models }: IProps) => { - const link = useMemo(() => { - return accessed_models && !accessed_models.includes(slug) ? '/account?scope=subscribe' : `/images/${slug}` - }, [accessed_models]) - - const { theme } = useThemeAndDevice() - - return ( - - - - - {title} - {text} - - - {blocked ? ( -
- - - Модель недоступна - -
- ) : ( - accessed_models && - !accessed_models.includes(slug) && ( -
- - Недоступно в текущем тарифе -
- ) - )} -
- - ) -} @@ -21,3 +21,16 @@ export function formatAndSortDates(inputArray: { date: string; value: number }[] return el }) } + +export function formatDate( + date: string | number | Date, + options: Intl.DateTimeFormatOptions, + lang?: string +) { + if (typeof date !== "object") { + date = new Date(typeof date === "number" ? date : Date.parse(date)) + } + + const formatter = Intl.DateTimeFormat("ru", options) + return formatter.format(date) +} @@ -29,7 +29,6 @@ export const getDeviceType = (): Device => { return isMobile ? 'mobile' : 'desktop' } - /** * @deprecated */ @@ -4,3 +4,4 @@ export { useConcat } from './reactive-concat' export * from './get-type-device' export * from './get-random-image' export * from './string' +export * from './date-helper' @@ -1,3 +1,13 @@ -export const c = (...classes: string[]) => { - return classes.filter(Boolean).join(' ') +export function c(...args: Array) { + return args.filter(Boolean).join(' ') +} + +export function commaSeparated(input: number | string | null | undefined): string { + const str: string = typeof input === 'number' || typeof input === 'string' ? String(input) : '' + + if (str) { + return str.replace(/\B(? { +export const useThemeAndDevice = (device?: 'desktop' | 'mobile', deviceOs?: DeviceOs) => { const theme = useAppSelector((state) => state.theme.theme) const desktop = device === 'desktop' + const ios = deviceOs === 'ios' - return { theme, desktop } + return { theme, desktop, ios } } @@ -0,0 +1,16 @@ +.badge { + display: inline-block; + padding: 3px 10px; + &_primary{ + color: var(--air-color); + font-weight: 500; + background-color: rgba($color: #8280ff, $alpha: 0.1); + border-radius: 5px; + } + + &_outline{ + color: rgba($color: #A4AAB5, $alpha: 0.6); + border: 1px solid rgba($color: #A4AAB5, $alpha: 0.6); + border-radius: 10px; + } +} \ No newline at end of file @@ -0,0 +1,18 @@ +import React from 'react' +import styles from './common-badge.module.scss' +import { c } from '#/shared/lib/helpers' + +export type CommonBagdeVariant = 'primary' | 'outline' + +interface CommonBagdeProps extends React.HTMLAttributes { + variant?: CommonBagdeVariant + className?: string +} + +export const CommonBagde = ({ variant = 'primary', children, className, ...props }: CommonBagdeProps) => { + return ( +
+ {children} +
+ ) +} @@ -0,0 +1 @@ +export * from './common-badge' \ No newline at end of file @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -0,0 +1,58 @@ +.button { + border: none; + outline: none; + background-color: transparent; + padding: 15px 25px; + cursor: pointer; + box-sizing: content-box; + + &_outline { + border-radius: 15px; + border: 0.15rem solid var(--new-ui-text-color); + color: var(--new-ui-text-color); + font-weight: 600; + transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; + font-size: 15px; + + &:hover { + background-color: white; + color: black; + } + + &_disabled { + opacity: 0.5; + + &:hover { + background-color: transparent; + color: white; + } + } + } + + &_primary-outline { + border-radius: 15px; + border: 1px solid var(--air-color); + color: var(--air-color); + font-weight: 600; + transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; + font-size: 15px; + + &:hover { + background-color: var(--air-color); + color: white; + } + } + + &_primary { + background-color: var(--air-color); + color: var(--new-ui-text-color); + border-radius: 12px; + font-weight: 500; + font-size: 15px; + transition: color 0.3s ease-in-out, background-color 0.3s ease-in-out; + &:hover { + background-color: var(--text-color-purple); + color: var(--new-ui-text-color); + } + } +} @@ -0,0 +1,26 @@ +import { c } from '#/shared/lib/helpers' +import styles from './common-button.module.scss' + +export type ButtonVariant = 'outline' | 'primary' | 'primary-outline' + +export interface CommonButtonProps extends React.ButtonHTMLAttributes { + children?: React.ReactNode + variant?: ButtonVariant +} + +export const CommonButton = ({ children, variant = 'outline', className, disabled, ...props }: CommonButtonProps) => { + return ( + + ) +} @@ -0,0 +1 @@ +export * from './common-button' \ No newline at end of file @@ -1,5 +1,5 @@ .button { - background-color: #8280ff; + background-color: #8280FF; border-radius: 13px; text-transform: none; color: white; @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -0,0 +1,35 @@ +import axios from 'axios' +import { useState, useEffect, HtmlHTMLAttributes } from 'react' + +export interface SvgIconProps extends HtmlHTMLAttributes { + width?: string | number + height?: string | number + color?: string + url: string +} + +export function SvgIcon({ url, color, width, height, ...props }: SvgIconProps) { + const [svgContent, setSvgContent] = useState('') + + async function onFetch() { + const response = await axios.get(url) + + if (response.status === 200) { + setSvgContent(response.data) + } + } + + useEffect(() => { + onFetch() + }, []) + + return ( +
+ ) +} @@ -0,0 +1 @@ +export * from './dynamic-svg' \ No newline at end of file @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -10,20 +10,25 @@ import { useChats } from '#/features/chats' import Title from '#/features/title/title' import { TutorialContext } from '#/features/tutorial-context/tutorial-context' import { ChatSelect } from '#/app/components/chat_select' -import { AllChatWindow } from '#/app/components/chat_window' import { ResetFilters } from '#/app/components/filters/reset_filters' import { setParams as setParametres } from '#/app/store/model-parametres-store' import { useAppSelector } from '#/app/store/store' import { DrawerCustom, Error, useModel, useShowData } from '#/shared' import model_api from '#/shared/api/models/api' -import { IModel } from '#/shared/api/models/models' -import styles from '#/shared/styles/chats-bot-pages.module.scss' +import styles from './chats-bot.module.scss' import { Chats } from '#/widgets/chats' -import { getDeviceType, getOs } from '#/shared/lib/helpers' +import { c, getDeviceType, getOs } from '#/shared/lib/helpers' import { NextPageWithLayout } from '#/pages/_app' import { getDefaultLayoutDynamic } from '#/widgets/layouts' import Head from 'next/head' +import LockSvg from '#/assets/svg/lock.svg?react' +import BlockedSvg from '#/assets/svg/blocked.svg?react' +import { IModelTag, IModel } from '#/entities/model-entity' +import { useThemeAndDevice } from '#/shared/lib/hooks' +import { SvgIcon } from '#/shared/ui/svg' +import { AllChatWindow } from '#/widgets/chat-window' + export interface ChatBotPageProps {} const Page: NextPageWithLayout = () => { @@ -68,12 +73,12 @@ const Page: NextPageWithLayout = () => { const deleteMessageMemo = useCallback(deleteMessage, [currentChat, messages]) - const { push } = useRouter() + const { push } = useRouter() React.useEffect(() => { if (data?.access) { model_api.getBotParams(router.asPath.split('/')[2], data.access).then((res) => { - if (!res.title) return push('/404') + if (!res.title) return push('/404') setBotParams(res) setModelType(res.slug) if (res.versions.length !== 0) { @@ -92,7 +97,12 @@ const Page: NextPageWithLayout = () => { } else { setVersion('') dispatch( - setParametres(res.parameters.reduce((a, v) => ({ ...a, [v.key]: v.values.default }), {})) + setParametres( + res.parameters.reduce( + (a, v) => ({ ...a, [v.key]: v.values.default }), + {} + ) + ) ) } }) @@ -118,7 +128,12 @@ const Page: NextPageWithLayout = () => { } else { setVersion(botParams.slug) dispatch( - setParametres(botParams.parameters.reduce((a, v) => ({ ...a, [v.key]: v.values.default }), {})) + setParametres( + botParams.parameters.reduce( + (a, v) => ({ ...a, [v.key]: v.values.default }), + {} + ) + ) ) } } @@ -132,14 +147,21 @@ const Page: NextPageWithLayout = () => { setParametres( botParams.parameters.reduce( (a, v) => - v.versions.includes(version) ? { ...a, [v.key]: v.values.default } : { ...a }, + v.versions.includes(version) + ? { ...a, [v.key]: v.values.default } + : { ...a }, {} ) ) ) } else { dispatch( - setParametres(botParams.parameters.reduce((a, v) => ({ ...a, [v.key]: v.values.default }), {})) + setParametres( + botParams.parameters.reduce( + (a, v) => ({ ...a, [v.key]: v.values.default }), + {} + ) + ) ) } } @@ -185,20 +207,48 @@ const Page: NextPageWithLayout = () => { return true } + const { theme } = useThemeAndDevice() + return ( <> {botParams ? botParams.title : 'Загрузка...'} - - <Box className={styles.main}> + <div className={styles.header}> + <Title + title={botParams?.title ? botParams?.title : ''} + rightSlot={ + <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> + } + type={'Чат-боты'} + linkBack={'/chat-bot'} + /> + </div> + <Box sx={{ height: 'calc(100% - 100px)' }} className={styles.main}> <Box className={styles.chatWindow}> <AllChatWindow deviceOs={deviceOs} input_types={botParams?.inputs} setFile={setFile} file={file} + blocked={botParams?.blocked} getMessagesPagination={getMessagesPagination} sendMessage={onSendMessage} deleteMessage={deleteMessageMemo} @@ -211,6 +261,14 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { modelTitle={botParams?.title} currentVersion={version} /> + {/* {botParams?.blocked && ( + <div className={styles.blocked}> + <BlockedSvg width={16} height={16} color='white' /> + <span className={c(styles.blocked__text)}> + Модель недоступна + </span> + </div> + )} */} </Box> <Box className={styles.settings}> <Chats @@ -227,27 +285,31 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { handleClickChatSetting={handleClickChatSetting} /> {desktop && ( - <Stack className='pd-30 bg-color-block border-radius-main' spacing={2}> - {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} - /> - </> - )} + <Stack + className='pd-30 bg-color-block border-radius-main' + spacing={2} + > + {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'} @@ -274,7 +336,9 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { viewBox='0 0 21 13' fill='none' xmlns='http://www.w3.org/2000/svg' - className={`${params ? 'rotate-180' : 'rotate-0'}`} + className={`${ + params ? 'rotate-180' : 'rotate-0' + }`} > <path fillRule='evenodd' @@ -287,7 +351,12 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { )} {botParams && botParams.parameters?.length > 0 ? ( - <Collapse in={params} orientation='vertical' collapsedSize={0} sx={{}}> + <Collapse + in={params} + orientation='vertical' + collapsedSize={0} + sx={{}} + > <BotParamsMap currentVersion={version} params={botParams?.parameters} @@ -312,29 +381,33 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { </Stack> )} {!desktop && ( - <DrawerCustom open={openFiltersMobile} onClose={hideMobileSettings}> + <DrawerCustom + open={openFiltersMobile} + onClose={hideMobileSettings} + > <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?.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 @@ -0,0 +1,7 @@ +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; + padding-top: 40px; + margin-right: 60px; +} @@ -3,12 +3,12 @@ import { useEffect, useState } from 'react' import { Box, Typography } from '@mui/material' import CircularProgress from '@mui/material/CircularProgress' import { useSession } from 'next-auth/react' - -import ChatCard from '#/app/components/chat_bot_card' import { useAppSelector } from '#/app/store/store' import model_api from '#/shared/api/models/api' import { NextPageWithLayout } from '#/pages/_app' -import { IShortModel } from '#/entities/model-entity' +import { ChatModelCard, IShortModel } from '#/entities/model-entity' + +import styles from './chat-bots.module.scss' const Page: NextPageWithLayout = () => { const [bots, setBots] = useState<IShortModel[] | null>(null) @@ -16,32 +16,25 @@ const Page: NextPageWithLayout = () => { const user = useAppSelector((state) => state.user) useEffect(() => { - if (!data?.access) { - return - } - model_api - .getBots(data?.access) - .then((res) => { - setBots(res) - }) - .catch((err) => {}) - }, [data?.access]) + if (!data) return + + model_api.getBots(data.access).then((res) => { + setBots(res) + }) + }, [data]) return ( <> - <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}>Чат-боты</Typography> - <Box display='flex' flexWrap='wrap' width='100%' justifyContent='start'> + <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}> + Чат-боты + </Typography> + <div className={styles.cards}> {bots ? ( - bots.map((item, idx) => ( - <ChatCard + bots.map((item) => ( + <ChatModelCard accessed_models={user.payment_plan.plan.accessed_models} - uid={item.uid} - key={idx} - blocked={item.blocked} - title={item.title} - icon={item.image} - text={item.description} - slug={item.slug} + key={item.uid} + {...item} /> )) ) : ( @@ -58,7 +51,7 @@ const Page: NextPageWithLayout = () => { }} /> )} - </Box> + </div> </> ) } @@ -0,0 +1,136 @@ +@keyframes fadein { + 0% { + opacity: 0; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.header { + width: 70%; + + @media screen and (max-width: 768px) { + width: 100%; + } +} + +.tag { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--air-color); + 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; + + &__icon { + color: white; + } + + &: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: white; + font-weight: 500; + padding-left: 10px; + font-size: 14px; + opacity: 0; + display: none; + min-width: max-content !important; + } +} + +.tags { + min-width: 100%; + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + @media screen and (max-width: 768px) { + display: none; + } +} + +.main { + width: 100%; + display: flex; + justify-content: flex-start; + align-items: stretch; + gap: 20px; + // margin-bottom: 24px; + + .chatWindow { + width: 70%; + position: relative; + } + + .settings { + width: 25%; + } + + @media (max-width: 768px) { + width: 100%; + display: flex; + + flex-direction: column-reverse; + + .chatWindow { + width: 100%; + } + + .settings { + width: 100%; + } + } +} + +.icon { +} + +.blocked { + display: flex; + align-items: center; + justify-content: center; + background-color: #c32528; + // padding-right: 20px; + padding: 8px 15px; + gap: 5px; + position: absolute; + left: 0; + bottom: -10px; + width: 100%; + border-radius: 0px 0px 15px 15px; + + + // @media screen and (max-width: 768px) { + // display: none; + // } + + &__text { + color: white; + font-size: 14px; + font-weight: 600; + } +} @@ -0,0 +1,148 @@ +.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; + } +} + + + +.blocked { + display: flex; + align-items: center; + justify-content: center; + background-color: white; + border-radius: 100px; + padding-right: 20px; + padding: 8px 12px; + width: max-content; + + span { + color: #ff2372; + font-weight: 500; + padding-left: 10px; + font-size: 14px; + width: max-content; + } +} + +.icon { +} + +.tags { + min-width: 100%; + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + + @media screen and (max-width: 768px) { + display: none; + } +} + +.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; + + &__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 { + display: flex; + align-items: center; + justify-content: center; + background-color: #c32528; + // padding-right: 20px; + padding: 8px 15px; + gap: 5px; + position: absolute; + left: 0; + + width: 100%; + border-radius: 15px 15px 0px 0px; + top: -23px; + + + @media screen and (max-width: 768px) { + top: unset; + bottom: -30px; + z-index: 1; + border-radius: 0px 0px 15px 15px; + } + + &__text { + color: white !important; + font-size: 14px; + font-weight: 600; + } +} @@ -1,263 +1,128 @@ -import * as React from 'react' -import { useRef } from 'react' -import { useDispatch } from 'react-redux' -import { Collapse, Typography } from '@mui/material' -import Box from '@mui/material/Box' -import Stack from '@mui/material/Stack' -import { useRouter } from 'next/router' -import { useSession } from 'next-auth/react' - -import { Setting } from '#/domains/images-bots/babes/types' -import BotParamsMap from '#/features/bot-params/bot-params-map' -import Title from '#/features/title/title' import { ChatSelect } from '#/app/components/chat_select' import { ResetFilters } from '#/app/components/filters/reset_filters' import { UniqInput } from '#/app/components/uniq_input' -import { setParams as setParametres } from '#/app/store/model-parametres-store' -import { useAppSelector } from '#/app/store/store' -import { DrawerCustom, Error } from '#/shared' -import model_api from '#/shared/api/models/api' -import { useModelImages } from '#/shared/api/models/endpoints' -import { IModel } from '#/shared/api/models/models' -import { useShowData } from '#/shared/lib/hooks' +import { useImageBot } from '#/entities/model-entity/model/use-image-bot' +import BotParamsMap from '#/features/bot-params/bot-params-map' +import { useImageBotCreateImage } from '#/features/image-bot-create-image' +import { useImagesBotFilters } from '#/features/image-bot-filters' +import { useImagesUniqInput } from '#/features/image-bot-input' +import { useImageBotPagination } from '#/features/image-bot-pagination' +import Title from '#/features/title/title' +import { useShowData, DrawerCustom, Error, Loader } from '#/shared' +import { useThemeAndDevice } from '#/shared/lib/hooks' import { ArrowDownScroll } from '#/shared/ui/icon-components/scroll-down-arrow' -import { ImageMessagesList } from '#/widgets/messages/ui/image-messages-list' -import { getDeviceType, getOs } from '#/shared/lib/helpers' +import { useImagesPagination, ImageMessagesList } from '#/widgets/messages' +import { Layout } from '#/app/layout' +import { Box, Typography, Collapse, Stack } from '@mui/material' +import { useSession } from 'next-auth/react' import Head from 'next/head' +import { useRouter } from 'next/router' +import { useEffect, useMemo } from 'react' +import { c, getDeviceType, getOs } from '#/shared/lib/helpers' +import { Device, DeviceOs } from '#/shared/lib/types/entities' +import LockSvg from '#/assets/svg/lock.svg?react' +import BlockedSvg from '#/assets/svg/blocked.svg?react' + +import styles from './image-model.module.scss' import { NextPageWithLayout } from '#/pages/_app' +import { SvgIcon } from '#/shared/ui/svg' const ImageModelPage: NextPageWithLayout = () => { - const [image, setImage] = React.useState<File | null>(null) + const { query } = useRouter() + + const { + botParams, + version, + modelType, + fetchBotParams, + resetParams, + setDefaultParams, + setVersion, + } = useImageBot(query.slug as string) const deviceType = getDeviceType() const deviceOs = getOs() - const desktop = deviceType === 'desktop' - const ios = deviceOs === 'ios' + const { ios, desktop } = useThemeAndDevice(deviceType, deviceOs) const { error, showError } = useShowData() - const [openFiltersMobile, setOpenFiltersMobile] = React.useState<boolean>(false) - const router = useRouter() - const { data } = useSession() - const [botParams, setBotParams] = React.useState<IModel | null>(null) - const [version, setVersion] = React.useState<string>('') - const [modelType, setModelType] = React.useState<string>('') - const [params, setParams] = React.useState<boolean>(false) - const includeParams = useAppSelector((state) => state.params.params) - const dispatch = useDispatch() - - const { messages, loading, createImage, isComplete, getMessagesPagination } = useModelImages<Setting>( - showError, - modelType, - deviceType - ) - - const [chatScrollHeight, setChatScrollHeight] = React.useState(0) - const [scrollBottom, setScrollBottom] = React.useState(0) - const refScrollMobile = useRef<any>() - const [isPaginating, setIsPaginating] = React.useState(false) - - React.useEffect(() => { - model_api - .getBotParams(router.asPath.split('/')[2], data?.access) - .then((res) => { - if (!res.title) return router.push('/404') - setBotParams(res) - setModelType(res.slug) - if (res.versions.length !== 0) { - setVersion(res.versions[0].slug) - dispatch( - setParametres( - res.parameters.reduce( - (a, v) => - v.versions.includes(res.versions[0].slug) - ? { ...a, [v.key]: v.values.default } - : { ...a }, - {} - ) - ) - ) - } else { - setVersion('') - dispatch( - setParametres(res.parameters.reduce((a, v) => ({ ...a, [v.key]: v.values.default }), {})) - ) - } - }) - .catch((err) => {}) - }, [data?.access, router.query]) - - const onLoadImage = (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.files) { - setImage(event.target.files[0]) - // showError('Файл успешно загружен, можете отправлять его!') - } - } - - const viewMobileSettings = () => { - setOpenFiltersMobile(true) - } - - const hideMobileSettings = () => { - setOpenFiltersMobile(false) - } - - const resetParams = () => { - if (botParams) { - dispatch(setParametres({})) - if (botParams.versions.length !== 0) { - setVersion(botParams.versions[0].slug) - dispatch( - setParametres( - botParams.parameters.reduce( - (a, v) => - v.versions.includes(botParams.versions[0].slug) - ? { ...a, [v.key]: v.values.default } - : { ...a }, - {} - ) - ) - ) - } else { - setVersion(botParams.slug) - dispatch( - setParametres(botParams.parameters.reduce((a, v) => ({ ...a, [v.key]: v.values.default }), {})) - ) - } - } - } - const setDefaultParams = () => { - if (botParams) { - dispatch(setParametres({})) - if (version !== '') { - dispatch( - setParametres( - botParams.parameters.reduce( - (a, v) => - v.versions.includes(version) ? { ...a, [v.key]: v.values.default } : { ...a }, - {} - ) - ) - ) - } else { - dispatch( - setParametres(botParams.parameters.reduce((a, v) => ({ ...a, [v.key]: v.values.default }), {})) - ) - } - } - } + const router = useRouter() - const onCreateImage = (input: string, required: (string | null)[]) => { - if (required.includes('text') && (input === '' || input === null)) { - showError('Введите сообщение!') - return false - } - if (required.includes('image') && image === null) { - showError('Прикрепите изображение!') - return false - } - if (required.includes('zip') && image === null) { - showError('Прикрепите архив!') - return false - } - let data = {} - if (version === '') { - data = { - ...includeParams, - } - } else { - data = { - version: version, - ...includeParams, - } - } + const { data: session } = useSession() - createImage({ - content: input, - file: image, - info: { - ...data, - }, - }) - return true - } + const { openFiltersMobile, setOpenFiltersMobile, params, setParams, includeParams } = + useImagesBotFilters() - React.useEffect(() => { - if (isComplete) { - if (!desktop) { - const block = refScrollMobile.current - if (block) { - //@ts-ignore - block.scrollTop = block.scrollHeight - } - } else { - window.scroll(0, 0) - } - } - }, [isComplete]) + const { + refScrollMobile, + refScrollDesktop, + mobileScrollContainer, + onObserverMounted, + setMessages, + fetchMessages, + loading, + offset, + messages, + } = useImageBotPagination(deviceType) - const handleMobileScroll = () => { - setScrollBottom( - refScrollMobile.current?.scrollHeight - - refScrollMobile.current?.scrollTop - - refScrollMobile.current?.clientHeight - ) + const { createImage, isComplete, createLoading } = useImageBotCreateImage( + showError, + modelType, + deviceType, + setMessages, + mobileScrollContainer + ) - if (refScrollMobile.current && messages?.length !== 0) { - const { scrollTop, scrollHeight, clientHeight } = refScrollMobile.current - if (scrollTop === 0) { - if (getMessagesPagination) { - setIsPaginating(true) - getMessagesPagination(deviceType) - } - } - } - } + const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput( + version, + includeParams, + createImage + ) - const handleScroll = () => { - if (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight) { - setIsPaginating(true) - getMessagesPagination(deviceType) - } + async function onFetch() { + await Promise.all([fetchBotParams()]) } - React.useEffect(() => { - window.addEventListener('scroll', handleScroll) - return () => { - window.removeEventListener('scroll', handleScroll) - } - }, []) - - React.useEffect(() => { - const block = deviceType === 'desktop' ? window : refScrollMobile.current - if (block) { - if (messages != undefined && !isPaginating) { - setChatScrollHeight(block.scrollHeight) - const time = setTimeout(() => { - //@ts-ignore - block.scrollTo({ - top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку - }) - }, 350) - return () => clearTimeout(time) - } else if (messages != undefined && isPaginating) { - //@ts-ignore - block.scrollTop = block.scrollHeight - chatScrollHeight + useEffect(() => { + if (!session) return + onFetch() + onObserverMounted() + }, [session?.access]) - setChatScrollHeight(block.scrollHeight) - } - } - setIsPaginating(false) - }, [messages]) + const { theme } = useThemeAndDevice() return ( <> <Head> - <title>{botParams ? botParams.title : 'Загрузка...'} + {botParams ? botParams?.title : 'Загрузка...'} - + <div className={styles.header}> + <Title + title={botParams ? botParams.title : 'Загрузка...'} + type={'Изображения'} + linkBack={'/images'} + rightSlot={ + <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> + } + /> + </div> <Box display={'flex'} justifyContent='space-between' @@ -279,7 +144,14 @@ const ImageModelPage: NextPageWithLayout = () => { > {desktop ? ( <> - <Stack alignItems='center' sx={{ marginBottom: '15px' }}> + <Stack + alignItems='center' + sx={{ + marginBottom: '15px', + position: 'relative', + zIndex: 1, + }} + > {botParams && ( <UniqInput currentVersion={version} @@ -288,85 +160,135 @@ const ImageModelPage: NextPageWithLayout = () => { image={image} value={prompt} desktop={desktop} - loading={loading} + blocked={botParams.blocked} + loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} unpinImage={() => setImage(null)} - viewMobileSettings={viewMobileSettings} + 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' }}> + <Box + className={'bg-color-block border-radius-main'} + sx={{ + padding: '30px', + position: 'relative', + }} + > <ImageMessagesList - getMessagesPagination={() => getMessagesPagination(deviceType)} - isComplete={isComplete} + isComplete={loading} device={deviceType} images={messages} + getMessagesPagination={fetchMessages} /> - </Box> - </> - ) : ( - <Box className={'bg-color-block border-radius-main'}> - {scrollBottom > 500 && ( <Box sx={{ position: 'absolute', - left: 0, - right: 0, - width: 'fit-content', - cursor: 'pointer', - margin: '0 auto', - bottom: '100px', - zIndex: 10, + bottom: '0', + visibility: 'hidden', + height: '800px', + width: '100%', }} - onClick={() => { - const block = refScrollMobile.current - - block.scrollTo({ - top: block.scrollHeight, - behavior: 'smooth', // добавляем плавную прокрутку - }) + 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%)', }} > - <ArrowDownScroll /> - </Box> - )} + <Loader /> + </div> + </div> + <Box - ref={refScrollMobile} sx={{ padding: '30px', - height: ios - ? 'calc(200px + (400 - 200) * ((100vh - 400px) / (600 - 400)) - 100px)' - : 'calc(200px + (400 - 200) * ((100vh - 400px) / (600 - 400)))', + height: 'calc(100dvh - 116px - 61px - 10px)', overflowY: 'scroll', overflowX: 'hidden', + position: 'relative', }} + ref={mobileScrollContainer} className={'smallScroll'} - onScroll={handleMobileScroll} > + <div + style={{ position: 'absolute', top: 300 }} + ref={refScrollMobile} + ></div> <ImageMessagesList - getMessagesPagination={() => getMessagesPagination(deviceType)} - isComplete={isComplete} + isComplete={loading} device={deviceType} images={messages} + getMessagesPagination={fetchMessages} /> </Box> - <Stack alignItems='center'> + <Stack + alignItems='center' + sx={{ position: 'relative', zIndex: 10 }} + > {botParams && ( <UniqInput currentVersion={version} styles={'images'} input_types={botParams.inputs} image={image} + blocked={botParams.blocked} value={prompt} desktop={desktop} - loading={loading} + loading={createLoading} imageLoad={onLoadImage} sendMessage={onCreateImage} unpinImage={() => setImage(null)} - viewMobileSettings={viewMobileSettings} + 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> )} @@ -438,11 +360,19 @@ const ImageModelPage: NextPageWithLayout = () => { )} {botParams && botParams.parameters?.length > 0 ? ( - <Collapse in={params} orientation='vertical' collapsedSize={0} sx={{}}> - <BotParamsMap currentVersion={version} params={botParams?.parameters} /> + <Collapse + in={params} + orientation='vertical' + collapsedSize={0} + sx={{}} + > + <BotParamsMap + currentVersion={version} + params={botParams?.parameters} + /> <ResetFilters desktop={desktop} - closeDrawer={hideMobileSettings} + closeDrawer={() => setOpenFiltersMobile(false)} reset={resetParams} /> </Collapse> @@ -459,7 +389,10 @@ const ImageModelPage: NextPageWithLayout = () => { )} </Stack> )} - <DrawerCustom open={openFiltersMobile} onClose={hideMobileSettings}> + <DrawerCustom + open={openFiltersMobile} + onClose={() => setOpenFiltersMobile(false)} + > <Stack spacing={1} padding={2.4}> {botParams?.versions && botParams.versions.length !== 0 ? ( <> @@ -497,9 +430,12 @@ const ImageModelPage: NextPageWithLayout = () => { > ПАРАМЕТРЫ </Typography> - <BotParamsMap currentVersion={version} params={botParams?.parameters} /> + <BotParamsMap + currentVersion={version} + params={botParams?.parameters} + /> <ResetFilters - closeDrawer={hideMobileSettings} + closeDrawer={() => setOpenFiltersMobile(false)} desktop={desktop} reset={resetParams} /> @@ -0,0 +1,7 @@ +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; + padding-top: 40px; + margin-right: 60px; +} @@ -8,7 +8,9 @@ import { useAppSelector } from '#/app/store/store' import model_api from '#/shared/api/models/api' import { Card } from '#/shared/card/card' import { NextPageWithLayout } from '#/pages/_app' -import { IShortModel } from '#/entities/model-entity' +import { ImageModelCard, IShortModel } from '#/entities/model-entity' + +import styles from './image-models.module.scss' export const ImageModelsPage: NextPageWithLayout = () => { const [bots, setBots] = useState<IShortModel[] | null>(null) @@ -23,20 +25,17 @@ export const ImageModelsPage: NextPageWithLayout = () => { return ( <> - <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}>Изображения</Typography> - <Box display='flex' flexWrap='wrap'> + <Typography sx={{ fontSize: 24, fontWeight: 'bold', marginTop: '25px' }}> + Изображения + </Typography> + <div className={styles.cards}> {bots ? ( - bots.map((item, idx) => { + bots.map((item) => { return ( - <Card - blocked={item.blocked} + <ImageModelCard accessed_models={user.payment_plan.plan.accessed_models} - uid={item.uid} - key={idx} - slug={item.slug} - text={item.description} - title={item.title} - icon={item.image} + {...item} + key={item.uid} /> ) }) @@ -54,7 +53,7 @@ export const ImageModelsPage: NextPageWithLayout = () => { }} /> )} - </Box> + </div> </> ) } @@ -0,0 +1,28 @@ +.blocked { + align-items: center; + justify-content: center; + background-color: #c32528; + padding: 8px 15px; + gap: 5px; + position: absolute; + left: 0; + // display: none; + display: flex; + bottom: -21px; + width: 100%; + border-radius: 0px 0px 15px 15px; + + // @media screen and (max-width: 768px) { + // top: -30px; + // bottom: unset; + // z-index: 1; + // display: flex; + // border-radius: 15px 15px 0px 0px; + // } + + &__text { + color: white !important; + font-size: 14px; + font-weight: 600; + } +} @@ -4,10 +4,45 @@ import axios from 'axios' import { UniqInput } from '#/app/components/uniq_input' import { useAppSelector } from '#/app/store/store' -import { ChatProps } from '#/shared/lib/types/model' import { ChatMessagesList } from '#/widgets/messages' +import BlockedSvg from '#/assets/svg/blocked.svg?react' + +import styles from './chat-window.module.scss' + import 'intro.js/introjs.css' +import { c } from '#/shared' +import { Device, DeviceOs } from '#/shared/lib/types/entities' +import { IModelInputs } from '#/entities/model-entity' +import { Message } from '#/entities/message' + +interface Elements { + element: React.ReactElement + position: string + onClick?: () => void +} + +export interface ChatProps<T> { + device: Device + deviceOs?: DeviceOs + modelType: string + messages: Message[] + sendMessage: (message: string, required: (string | null)[]) => boolean + openMobileFilters: () => void + setting?: T + loading: boolean + file?: any + setFile: React.Dispatch<React.SetStateAction<File | null>> + isCalculating: boolean + elements?: Elements[] + model?: string + blocked?: boolean + getMessagesPagination?: () => Promise<void> + input_types?: IModelInputs[] + deleteMessage: (message_uid: string) => void + modelTitle: string | undefined + currentVersion: string +} function Chat<T>({ device, @@ -22,6 +57,7 @@ function Chat<T>({ input_types, deleteMessage, modelTitle, + blocked, currentVersion, deviceOs, }: ChatProps<T>) { @@ -30,14 +66,17 @@ function Chat<T>({ const theme = useAppSelector((state) => state.theme.theme) const [resendValue, setResendValue] = React.useState<string>('') - const onLoadImage = useCallback((event: React.ChangeEvent<HTMLInputElement> | null, file?: File) => { - if (event?.target.files) { - setFile(event.target.files[0]) - } - if (file) { - setFile(file) - } - }, []) + const onLoadImage = useCallback( + (event: React.ChangeEvent<HTMLInputElement> | null, file?: File) => { + if (event?.target.files) { + setFile(event.target.files[0]) + } + if (file) { + setFile(file) + } + }, + [] + ) const handleSetResetValue = useCallback( (value: string) => { @@ -71,15 +110,11 @@ function Chat<T>({ : theme === 'light' ? 'white' : '#151518', - padding: desktop ? 4 : 1.5, + padding: desktop ? (blocked ? 2 : 2) : 1.5, paddingTop: 0, - paddingBottom: 2, + paddingBottom: blocked ? (desktop ? 3.8 : 5) : 1, width: '100%', - height: desktop - ? '75vh' - : deviceOs === 'ios' - ? 'calc(200px + (400 - 200) * ((100vh - 400px) / (650 - 400)) - 100px)' - : 'calc(200px + (400 - 200) * ((100vh - 400px) / (650 - 400)))', + height: desktop ? '75vh' : 'calc(100dvh - 245px)', overflow: 'hidden', }} > @@ -94,19 +129,32 @@ function Chat<T>({ modelTitle={modelTitle} loading={loading} /> - <UniqInput - resendValue={resendValue} - currentVersion={currentVersion} - viewMobileSettings={openMobileFilters} - input_types={input_types} - styles={'images'} - loading={loading} - desktop={desktop} - sendMessage={sendMessage} - image={file} - unpinImage={clearImage} - imageLoad={onLoadImage} - /> + + <div style={{ position: 'relative', zIndex: 10 }}> + <UniqInput + resendValue={resendValue} + currentVersion={currentVersion} + viewMobileSettings={openMobileFilters} + input_types={input_types} + styles={'chats'} + loading={loading} + desktop={desktop} + blocked={blocked} + sendMessage={sendMessage} + image={file} + unpinImage={clearImage} + imageLoad={onLoadImage} + /> + + {blocked && ( + <div className={styles.blocked}> + <BlockedSvg width={16} height={16} color='white' /> + <span className={c(styles.blocked__text)}> + Модель недоступна + </span> + </div> + )} + </div> </Stack> </> ) @@ -0,0 +1 @@ +export * from './chat-window' \ No newline at end of file @@ -0,0 +1 @@ +export * from './ui' \ No newline at end of file @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Box } from '@mui/material' import { ThemeProvider } from '@mui/material/styles' import Head from 'next/head' @@ -34,6 +34,7 @@ export const Layout: React.FC<LayoutProps> = ({ const appState = useAppSelector((state) => state) const dispatch = useAppDispatch() + const { userLoading } = useAppSelector((state) => state.loading) const desktop = device === 'desktop' @@ -93,7 +94,13 @@ export const Layout: React.FC<LayoutProps> = ({ if (userLoading || isLoader || status === 'loading') { return ( - <Box width='100vw' height='100vh' display='flex' alignItems='center' justifyContent='center'> + <Box + width='100vw' + height='100vh' + display='flex' + alignItems='center' + justifyContent='center' + > <Loader size={50} /> </Box> ) @@ -102,7 +109,7 @@ export const Layout: React.FC<LayoutProps> = ({ return ( <> <Head> - <title>{titlePage} + {titlePage && {titlePage}} @@ -114,7 +121,7 @@ export const Layout: React.FC = ({ id={'layout'} sx={{ width: '100%', - height: desktop ? '100%' : '100vh', + height: desktop ? '100%' : '100dvh', padding: desktop ? '0px' : '10px', }} > @@ -125,7 +132,10 @@ export const Layout: React.FC = ({ > {desktop ? ( - + (API_URL + `/media/gallery/images?limit=${limit}&offset=${offset}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} @@ -0,0 +1 @@ +export * from './image-messages.routes' \ No newline at end of file @@ -0,0 +1,17 @@ +type DateResponse = { + month: string + year: string + day: string +} + +export const getDateFromString = (inputDate: string): DateResponse | null => { + if (inputDate) { + const date = inputDate.split('T')[0].split('-') + return { + day: date[2], + month: date[1], + year: date[0], + } + } + return null +} @@ -0,0 +1,21 @@ +export const getDayMontsString = (inputDate: string): string => { + const monthNames = [ + 'января', + 'февраля', + 'марта', + 'апреля', + 'мая', + 'июня', + 'июля', + 'августа', + 'сентября', + 'октября', + 'ноября', + 'декабря', + ] + + const date = new Date(inputDate) + const day = date.getDate() + const month = monthNames[date.getMonth()] + return `${day} ${month}` +} @@ -0,0 +1,10 @@ +import { MessageSend } from '#/entities/message' + +export function formDataHelper(file: File, dataForSend: MessageSend): FormData { + const FD = new FormData() + FD.append('file', file) + FD.append('info', JSON.stringify(dataForSend.info)) + FD.append('content', JSON.stringify(dataForSend.content)) + + return FD +} @@ -1,18 +1,5 @@ export const getDayMontsString = (inputDate: string): string => { - const monthNames = [ - 'января', - 'февраля', - 'марта', - 'апреля', - 'мая', - 'июня', - 'июля', - 'августа', - 'сентября', - 'октября', - 'ноября', - 'декабря', - ] + const monthNames = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'] const date = new Date(inputDate) const day = date.getDate() @@ -0,0 +1,3 @@ +export * from './date-from-string' +export * from './day-months-string' +export * from './form-data' \ No newline at end of file @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Box, Menu, MenuItem, Skeleton, Slide, Typography } from '@mui/material' import Stack from '@mui/material/Stack' import Image from 'next/image' @@ -7,7 +7,9 @@ import FullScreenModal from '#/features/image-modal/full-screen-modal' import { useAppSelector } from '#/app/store/store' import { TooltipCustom } from '#/shared' import { Message } from '#/shared/lib/types/model' -import { BotMessage } from '#/widgets/messages/message-components/bot-message' +import { BotMessage } from '#/widgets/messages' +import { Markdown } from '../../markdown/markdown' +import { formatDate } from '#/shared/lib/helpers' interface IMessagesList { // messageResponse: Message[] | null @@ -18,19 +20,18 @@ interface IMessagesList { device: 'mobile' | 'desktop' modelType: string isNewMessage: boolean - setCurrentSrc: (value: string) => void message: Message - setModal: (value: boolean) => void deleteMessage?: (message_uid: string) => void modelTitle: string | undefined setResendValue: (value: string) => void onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void } -export const UserMessage = React.memo(function UserMessage({ setModal, setCurrentSrc, ...props }: IMessagesList) { +export const UserMessage = React.memo(function UserMessage(props: IMessagesList) { const [anchorEl, setAnchorEl] = React.useState(null) const open = Boolean(anchorEl) const desktop = props.device === 'desktop' + const [modal, setModal] = useState(false) const [loaded, setLoaded] = useState(false) const copy = (text: string) => { @@ -43,6 +44,13 @@ export const UserMessage = React.memo(function UserMessage({ setModal, setCurren setAnchorEl(null) } + const fileName = useMemo(() => { + if (!props.message.file) return 'Подбираем имя файла...' + const matches = (props.message.file as string).match(/air-messages\/(.+?)\.(.+?)\?/) + if (!matches) return 'Подбираем имя файла...' + return `${matches[1]}.${matches[2]}` + }, []) + const resend = async () => { props.setResendValue(props.message.content) if (props.message.file !== null) { @@ -61,19 +69,37 @@ export const UserMessage = React.memo(function UserMessage({ setModal, setCurren } } + useEffect(() => { + console.log(props.message.file) + }, [props.message.file]) + + const flexStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', + gap: '12px', + color: '#7f7df3 !important', + fontWeight: '600 !important', + lineHeight: '140%', + letterSpacing: '0.2px', + cursor: 'pointer', + } + const theme = useAppSelector((state) => state.theme.theme) return ( <> + {props.message.file && ( + + )} {!props.message.from_model ? ( - + // + - {props.message.created_at.slice(10, 16).replace('T', ' ')} + {formatDate(props.message.created_at, { + hour: 'numeric', + minute: 'numeric', + })} {props.message.file ? ( - - - { - if (!props.message.file) return - setCurrentSrc(props.message.file.toString()) - setModal(true) - }} - onLoadingComplete={() => setLoaded(true)} - style={{ - objectFit: 'contain', - height: '100%', - width: '100%', - borderRadius: '13px', - cursor: 'pointer', - opacity: loaded ? '100%' : '0%', - minWidth: '100px', - minHeight: '100px', - }} - width={500} - height={500} - src={props.message.file.toString()} - alt={'К сожалению, изображение не загрузилось'} - /> - {!loaded && ( - + + { + if (!props.message.file) + return + setModal(true) + }} + onLoadingComplete={() => + setLoaded(true) + } + style={{ + objectFit: 'contain', height: '100%', + width: '100%', + borderRadius: '13px', + cursor: 'pointer', + opacity: loaded + ? '100%' + : '0%', + minWidth: '100px', + minHeight: '100px', }} - variant='rectangular' + width={500} + height={500} + src={ + props.message.file + .toString() + .split('?type')[0] + } + alt={ + 'К сожалению, изображение не загрузилось' + } /> - )} - - - - - - - - - - - - - - - { - copy(props.message.content) + position: 'absolute', + top: '0', + left: '0', + zIndex: '1', + borderRadius: 5, + width: '100%', + height: '100%', }} + variant='rectangular' + /> + )} + + + + - - + - - - - Копировать - - + + + + + { - if (props.deleteMessage) - props.deleteMessage(props.message.uid) + > + { + copy( + props.message + .content + ) + }} + > + + + + + + + Копировать + + { + if ( + props.deleteMessage + ) + props.deleteMessage( + props + .message + .uid + ) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 + ? 'pre-wrap' + : 'pre', + }} + > + {/**/} + {props.message.content} + + + + ) : ( + + + { + if (props.message.file) { + window.open( + props.message + .file as string, + '_blank' + ) + } + }} + className='smallScroll' + sx={{ + overflowY: 'scroll', + position: 'relative', + padding: '15px 23px', + + border: `1px solid ${ + theme === 'dark' + ? '#303035' + : '#EFF0F2' + }`, + + boxShadow: 'none', + fontSize: '15px', + marginTop: 0.5, + textAlign: 'left', + fontFamily: + 'Raleway,sans-serif', + borderRadius: '13px', + '& p': { + color: 'inherit', + fontStyle: 'inherit', + }, + ...flexStyle, + }} + > + - Удалить - - + + + + - - {!props.message.is_sent && ( - + + + + + + + + + + + + - )} - - 30 - ? 'pre-wrap' - : 'pre', - }} - > - {/**/} - {props.message.content} - + > + { + copy( + props.message + .content + ) + }} + > + + + + + + + Копировать + + { + if ( + props.deleteMessage + ) + props.deleteMessage( + props + .message + .uid + ) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 + ? 'pre-wrap' + : 'pre', + }} + > + {/**/} + {props.message.content} + + - + ) ) : ( { if (props.deleteMessage) - props.deleteMessage(props.message.uid) + props.deleteMessage( + props.message.uid + ) }} > 30 ? 'pre-wrap' : 'pre', + props.message.content.length > 30 + ? 'pre-wrap' + : 'pre', }} > {/**/} @@ -533,7 +915,7 @@ export const UserMessage = React.memo(function UserMessage({ setModal, setCurren )} - + ) : ( void, type: string, device: Device) { + const { data } = useSession() + + const [messages, setMessages] = useState([]) + + const [loading, setLoading] = useState(false) + + const [offset, setOffset] = useState(0) + + const dispatch = useAppDispatch() + + async function onFetch() { + if (!data) return + + setLoading(true) + console.log(offset) + const { data: answer, ...response } = await getImagesGalery(data.access, offset) + setLoading(false) + + if (response.status >= 400) return showError('Ошибка загрузки чата') + + if (!Array.isArray(answer)) return showError('Ошибка загрузки чата') + + if (device === 'desktop') { + setMessages(answer) + return setOffset(answer.length) + } + + setMessages(answer.reverse()) + setOffset(answer.length) + } + + useEffect(() => { + onFetch() + }, [data, type]) + + const getMessagesPagination = async (device: 'mobile' | 'desktop') => { + if (!data) return + + setLoading(true) + const { data: answer, ...response } = await getImagesGalery(data.access, offset) + setLoading(false) + + if (response.status >= 400) return showError('Ошибка загрузки чата') + + if (!Array.isArray(answer)) return showError('Ошибка загрузки сообщений') + + if (device === 'mobile') { + const newMessages = answer.reverse() + setMessages([...newMessages, ...messages]) + return setOffset((prev) => prev + answer.length) + } + + setMessages([...messages, ...answer]) + setOffset((prev) => prev + answer.length) + } + + return { messages, loading, getMessagesPagination } +} @@ -1,4 +1,4 @@ -import { Message } from '#/shared/lib/types/model' +import { Message } from '#/entities/message' import { useMemo, useState } from 'react' export const useMessages = (messages: Message[]) => { @@ -7,13 +7,14 @@ export const useMessages = (messages: Message[]) => { const [loaded, setLoaded] = useState(false) // типизация ну супер кривая))) - const computedLibraryImages = useMemo(() => { + const computedLibraryImages = useMemo[]>(() => { return messages .map((el) => { if (el.file && (el.file as any).includes('.zip')) return null - return el + if (/\.jpg|\.png|\.jpeg|\.gif|\.webp|\.svg/.test(el.file as string)) return el + return null }) - .filter((el) => el !== null) as Message[] + .filter((el) => el !== null) as Message[] }, [messages]) return { @@ -0,0 +1,843 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Box, Menu, MenuItem, Skeleton, Slide, Typography } from '@mui/material' +import Stack from '@mui/material/Stack' +import Image from 'next/image' + +import { useAppSelector } from '#/app/store/store' +import { TooltipCustom } from '#/shared' +import { Message } from '#/shared/lib/types/model' +import { BotMessage } from '#/widgets/messages' +import { Markdown } from '../../markdown/markdown' +import { formatDate } from '#/shared/lib/helpers' + +interface IMessagesList { + // messageResponse: Message[] | null + // mode?: string + // sendMessage?: (title: string) => void + // getMessagesPagination?: () => Promise + + device: 'mobile' | 'desktop' + modelType: string + isNewMessage: boolean + setCurrentSrc: (value: string) => void + message: Message + setModal: (value: boolean) => void + deleteMessage?: (message_uid: string) => void + modelTitle: string | undefined + setResendValue: (value: string) => void + onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void +} + +export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setModal, ...props }: IMessagesList) { + const [anchorEl, setAnchorEl] = React.useState(null) + const open = Boolean(anchorEl) + const desktop = props.device === 'desktop' + const [loaded, setLoaded] = useState(false) + + const copy = (text: string) => { + navigator.clipboard.writeText(text) + } + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const fileName = useMemo(() => { + if (!props.message.file) return 'Подбираем имя файла...' + const matches = (props.message.file as string).match(/air-messages\/(.+?)\.(.+?)\?/) + if (!matches) return 'Подбираем имя файла...' + return `${matches[1]}.${matches[2]}` + }, []) + + const resend = async () => { + props.setResendValue(props.message.content) + if (props.message.file !== null) { + fetch(props.message.file as string, { + method: 'GET', + headers: {}, + }).then((response) => { + response.arrayBuffer().then(function (buffer) { + const url = new Blob([buffer]) + const binaryFile = new File([url], 'image.png') + if (props.onLoadImage) { + props.onLoadImage(null, binaryFile) + } + }) + }) + } + } + + useEffect(() => { + console.log(props.message.file) + }, [props.message.file]) + + const flexStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', + gap: '12px', + color: '#7f7df3 !important', + fontWeight: '600 !important', + lineHeight: '140%', + letterSpacing: '0.2px', + cursor: 'pointer', + } + + const theme = useAppSelector((state) => state.theme.theme) + + return ( + <> + + {!props.message.from_model ? ( + // + + + + + {formatDate(props.message.created_at, { + hour: 'numeric', + minute: 'numeric', + })} + + + + + {props.message.file ? ( + /\.jpg|\.png|\.jpeg|\.gif|\.webp|\.svg/.test( + props.message.file as string + ) ? ( + + + { + if (!props.message.file) return + setCurrentSrc(props.message.file.toString()) + setModal(true) + }} + onLoadingComplete={() => setLoaded(true)} + style={{ + objectFit: 'contain', + height: '100%', + width: '100%', + borderRadius: '13px', + cursor: 'pointer', + opacity: loaded ? '100%' : '0%', + minWidth: '100px', + minHeight: '100px', + }} + width={500} + height={500} + src={props.message.file.toString().split('?type')[0]} + alt={'К сожалению, изображение не загрузилось'} + /> + {!loaded && ( + + )} + + + + + + + + + + + + + + + { + copy(props.message.content) + }} + > + + + + + + + Копировать + + { + if (props.deleteMessage) + props.deleteMessage( + props.message.uid + ) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 + ? 'pre-wrap' + : 'pre', + }} + > + {/**/} + {props.message.content} + + + + ) : ( + + + { + if (props.message.file) { + window.open( + props.message.file as string, + '_blank' + ) + } + }} + className='smallScroll' + sx={{ + overflowY: 'scroll', + position: 'relative', + padding: '15px 23px', + + border: `1px solid ${ + theme === 'dark' ? '#303035' : '#EFF0F2' + }`, + + boxShadow: 'none', + fontSize: '15px', + marginTop: 0.5, + textAlign: 'left', + fontFamily: 'Raleway,sans-serif', + borderRadius: '13px', + '& p': { + color: 'inherit', + fontStyle: 'inherit', + }, + ...flexStyle, + }} + > + + + + + + + + + + + + + + + + + + + + + + + { + copy(props.message.content) + }} + > + + + + + + + Копировать + + { + if (props.deleteMessage) + props.deleteMessage( + props.message.uid + ) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 + ? 'pre-wrap' + : 'pre', + }} + > + {/**/} + {props.message.content} + + + + ) + ) : ( + + + + + + + + + + + + + + { + copy(props.message.content) + }} + > + + + + + + + Копировать + + { + if (props.deleteMessage) + props.deleteMessage(props.message.uid) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 ? 'pre-wrap' : 'pre', + }} + > + {/**/} + {props.message.content} + + + )} + + + + ) : ( + + )} + + + ) +}) @@ -0,0 +1,23 @@ +import React from 'react' +import { Box } from '@mui/material' +import Link from 'next/link' + +import { Message } from '#/shared/lib/types/model' + +export function AnswerWrap(props: { message: Message }) { + if (props.message.file) { + const messageText = props.message.from_model + ? 'Ваша ссылка для скачивания \n (нажмите для просмотра)' + : 'Тут ссылка на ваш файл (нажмите для просмотра)' + + return ( + + e.stopPropagation()} target='_blank' href={props.message.file.toString()}> + {messageText} + + + ) + } + + return <>{props.message.content} +} @@ -6,6 +6,7 @@ import Image from 'next/image' import { useAppSelector } from '#/app/store/store' import { TooltipCustom } from '#/shared' import { Markdown } from '#/widgets/markdown/markdown' +import { formatDate } from '#/shared/lib/helpers' export function BotMessage(props: any) { // const LazyCode = dynamic(() => import('src/widgets/chat-gpt-field/ui/code')) @@ -42,20 +43,19 @@ export function BotMessage(props: any) { } }, [props]) - const flexStyle = - props.message.file && props.modelType !== 'chatgpt' - ? { - display: 'flex', - alignItems: 'center', - justifyContent: 'start', - gap: '12px', - color: '#7f7df3 !important', - fontWeight: '600 !important', - lineHeight: '140%', - letterSpacing: '0.2px', - cursor: 'pointer', - } - : {} + const flexStyle = props.message.file + ? { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', + gap: '12px', + color: '#7f7df3 !important', + fontWeight: '600 !important', + lineHeight: '140%', + letterSpacing: '0.2px', + cursor: 'pointer', + } + : {} return ( // @@ -104,7 +104,10 @@ export function BotMessage(props: any) { marginRight: 4, }} > - {props.message.created_at.slice(10, 16).replace('T', ' ')} + {formatDate(props.message.created_at, { + hour: 'numeric', + minute: 'numeric', + })} @@ -135,7 +138,7 @@ export function BotMessage(props: any) { ...flexStyle, }} > - {props.message.file && props.modelType !== 'chatgpt' ? ( + {props.message.file ? ( + = memo( const onlyImageMessage = useMemo(() => { if (!messageResponse) return [] - console.log(messageResponse) return messageResponse - .map((item) => item) - .filter((item) => item.file !== null && !(item.file as string).includes('.zip')) as Message[] + .map((el) => { + if (el.file && (el.file as any).includes('.zip')) return null + if (/\.jpg|\.png|\.jpeg|\.gif|\.webp|\.svg/.test(el.file as string)) { + return { ...el, file: el.file?.toString().split('?type')[0]! } + } + return null + }) + .filter((el) => el !== null) as Message[] }, [messageResponse]) React.useEffect(() => { @@ -88,7 +93,7 @@ export const ChatMessagesList: React.FC = memo( }, [messageResponse]) const handleScroll = () => { - setScrollBottom( + setScrollBottom( paginationScroll.current?.scrollHeight - paginationScroll.current?.scrollTop - paginationScroll.current?.clientHeight @@ -144,7 +149,7 @@ export const ChatMessagesList: React.FC = memo( width: 'fit-content', cursor: 'pointer', margin: '0 auto', - bottom: '100px', + bottom: '150px', zIndex: 10, }} onClick={() => { @@ -1,3 +1,15 @@ +.model { + position: absolute; + top: 12px; + left: 12px; + z-index: 100; + background-color: rgba($color: #151518, $alpha: 0.5); + font-size: 13px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; + color: #fff; +} .wrap { display: flex; overflow: hidden; @@ -6,13 +6,13 @@ import Link from 'next/link' import { useAppSelector } from '#/app/store/store' import { ClientOnly, Success, TooltipCustom, useShowData } from '#/shared' -import { Message } from '#/shared/lib/types/model' import styles from './image-messages-list.module.scss' import { createPortal } from 'react-dom' import { useMessages } from '../model/use-messages' import { ImageIcons } from './image-icons' import { ImageModal } from '#/features/image-modal' +import { Message } from '#/entities/message' interface MessagesList { device: 'mobile' | 'desktop' @@ -1,2 +1,8 @@ +export * from './image-messages-list' +export * from './chat-messages-list' +export * from './answer-wrap' +export * from './bot-message' +export * from './preview-view' +export * from './user-message' +export * from './is-next-day' export * from './image-icons' -export * from './chat-messages-list' \ No newline at end of file @@ -2,9 +2,9 @@ import React, { memo, useMemo } from 'react' import { Box, Typography } from '@mui/material' import { Message } from '#/shared/lib/types/model' -import { getDateFromString } from '#/widgets/messages/lib/getDateFromString' -import { getDayMontsString } from '#/widgets/messages/lib/getDayMontsString' -import { UserMessage } from '#/widgets/messages/message-components/user-message' +import { getDateFromString } from '#/widgets/messages/lib/date-from-string' +import { getDayMontsString } from '#/widgets/messages/lib/day-months-string' +import { UserMessage } from '#/widgets/messages' interface IProps { message: Message @@ -12,9 +12,9 @@ interface IProps { messageResponse: Message[] | null modelTitle: string | undefined deleteMessage?: (message_uid: string) => void - setCurrentSrc: (value: string) => void + setCurrentSrc: (value: string) => void isNewMessage: boolean - setModal: (value: boolean) => void + setModal: (value: boolean) => void modelType: string device: 'mobile' | 'desktop' setResendValue: (value: string) => void @@ -29,8 +29,8 @@ export const IsNextDay = memo( messageResponse, modelTitle, deleteMessage, - setCurrentSrc, - setModal, + setCurrentSrc, + setModal, isNewMessage, modelType, device, @@ -65,9 +65,9 @@ export const IsNextDay = memo( setResendValue={setResendValue} modelTitle={modelTitle} deleteMessage={deleteMessage} - setCurrentSrc={setCurrentSrc} + setCurrentSrc={setCurrentSrc} key={message.uid} - setModal={setModal} + setModal={setModal} message={message} isNewMessage={isNewMessage} modelType={modelType} @@ -97,11 +97,11 @@ export const IsNextDay = memo( + Не знаете, с чего начать? + + Попробуйте, например, вот так: + + + {previewMessages.map((el) => { + return ( + props.setValue(el)} + sx={{ + cursor: 'pointer', + padding: '15px', + color: '#8280FF', + width: 'fit-content', + marginTop: '15px', + backgroundColor: 'rgba(130, 128, 255, 0.10)', + borderRadius: '12px', + }} + > + {el} + + ) + })} + + + ) +} @@ -0,0 +1,839 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Box, Menu, MenuItem, Skeleton, Slide, Typography } from '@mui/material' +import Stack from '@mui/material/Stack' +import Image from 'next/image' + +import { useAppSelector } from '#/app/store/store' +import { TooltipCustom } from '#/shared' +import { Message } from '#/shared/lib/types/model' +import { BotMessage } from '#/widgets/messages' +import { Markdown } from '../../markdown/markdown' +import { formatDate } from '#/shared/lib/helpers' + +interface IMessagesList { + // messageResponse: Message[] | null + // mode?: string + // sendMessage?: (title: string) => void + // getMessagesPagination?: () => Promise + + device: 'mobile' | 'desktop' + modelType: string + isNewMessage: boolean + setCurrentSrc: (value: string) => void + message: Message + setModal: (value: boolean) => void + deleteMessage?: (message_uid: string) => void + modelTitle: string | undefined + setResendValue: (value: string) => void + onLoadImage?: (event: React.ChangeEvent | null, file?: File) => void +} + +export const UserMessage = React.memo(function UserMessage({ setCurrentSrc, setModal, ...props }: IMessagesList) { + const [anchorEl, setAnchorEl] = React.useState(null) + const open = Boolean(anchorEl) + const desktop = props.device === 'desktop' + const [loaded, setLoaded] = useState(false) + + const copy = (text: string) => { + navigator.clipboard.writeText(text) + } + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const fileName = useMemo(() => { + if (!props.message.file) return 'Подбираем имя файла...' + const matches = (props.message.file as string).match(/air-messages\/(.+?)\.(.+?)\?/) + if (!matches) return 'Подбираем имя файла...' + return `${matches[1]}.${matches[2]}` + }, []) + + const resend = async () => { + props.setResendValue(props.message.content) + if (props.message.file !== null) { + fetch(props.message.file as string, { + method: 'GET', + headers: {}, + }).then((response) => { + response.arrayBuffer().then(function (buffer) { + const url = new Blob([buffer]) + const binaryFile = new File([url], 'image.png') + if (props.onLoadImage) { + props.onLoadImage(null, binaryFile) + } + }) + }) + } + } + + const flexStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'start', + gap: '12px', + color: '#7f7df3 !important', + fontWeight: '600 !important', + lineHeight: '140%', + letterSpacing: '0.2px', + cursor: 'pointer', + } + + const theme = useAppSelector((state) => state.theme.theme) + + return ( + <> + + {!props.message.from_model ? ( + // + + + + + {formatDate(props.message.created_at, { + hour: 'numeric', + minute: 'numeric', + })} + + + + + {props.message.file ? ( + /\.jpg|\.png|\.jpeg|\.gif|\.webp|\.svg|\.wav/.test( + props.message.file as string + ) ? ( + + + { + if (!props.message.file) return + setCurrentSrc(props.message.file.toString().split('?type')[0]) + setModal(true) + }} + onLoadingComplete={() => setLoaded(true)} + style={{ + objectFit: 'contain', + height: '100%', + width: '100%', + borderRadius: '13px', + cursor: 'pointer', + opacity: loaded ? '100%' : '0%', + minWidth: '100px', + minHeight: '100px', + }} + width={500} + height={500} + src={props.message.file.toString().split('?type')[0]} + alt={'К сожалению, изображение не загрузилось'} + /> + {!loaded && ( + + )} + + + + + + + + + + + + + + + { + copy(props.message.content) + }} + > + + + + + + + Копировать + + { + if (props.deleteMessage) + props.deleteMessage( + props.message.uid + ) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 + ? 'pre-wrap' + : 'pre', + }} + > + {/**/} + {props.message.content} + + + + ) : ( + + + { + if (props.message.file) { + window.open( + props.message.file as string, + '_blank' + ) + } + }} + className='smallScroll' + sx={{ + overflowY: 'scroll', + position: 'relative', + padding: '15px 23px', + + border: `1px solid ${ + theme === 'dark' ? '#303035' : '#EFF0F2' + }`, + + boxShadow: 'none', + fontSize: '15px', + marginTop: 0.5, + textAlign: 'left', + fontFamily: 'Raleway,sans-serif', + borderRadius: '13px', + '& p': { + color: 'inherit', + fontStyle: 'inherit', + }, + ...flexStyle, + }} + > + + + + + + + + + + + + + + + + + + + + + + + { + copy(props.message.content) + }} + > + + + + + + + Копировать + + { + if (props.deleteMessage) + props.deleteMessage( + props.message.uid + ) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 + ? 'pre-wrap' + : 'pre', + }} + > + {/**/} + {props.message.content} + + + + ) + ) : ( + + + + + + + + + + + + + + { + copy(props.message.content) + }} + > + + + + + + + Копировать + + { + if (props.deleteMessage) + props.deleteMessage(props.message.uid) + }} + > + + + + Удалить + + + + + {!props.message.is_sent && ( + 1 + )} + + 30 ? 'pre-wrap' : 'pre', + }} + > + {/**/} + {props.message.content} + + + )} + + + + ) : ( + + )} + + + ) +}) @@ -1,2 +1,4 @@ export * from './ui' +export * from './lib' +export * from './api' export * from './model' \ No newline at end of file @@ -1,12 +1,14 @@ export interface NavigationSearchModelLink { title: string slug: string + alternative_titles: string[] } export interface NavigationSearchLink { label: string + alternative_labels?: string[] url: string category: string external?: boolean - uid?: string + uid?: string } @@ -1,35 +1,35 @@ -import { NavigationSearchLink } from '../api/types' +import { NavigationSearchLink } from "../api/types"; export const staticLinks: NavigationSearchLink[] = [ { label: 'Дашборд', url: '/', - category: 'Навигация', + category: 'Навигация' }, { label: 'Оплата', url: '/account?scope=subscribe', - category: 'Навигация', + category: 'Навигация', }, { label: 'Настройки', url: '/account?scope=setting', - category: 'Навигация', + category: 'Навигация', }, { label: 'Настройки корп. аккаунта', url: '/account?scope=business', - category: 'Навигация', + category: 'Навигация', }, { label: 'API-ключи', url: '/api-keys', - category: 'Навигация', + category: 'Навигация', }, { label: 'Реквизиты', url: 'https://air.fail/requisites', - category: 'Навигация', + category: 'Навигация', external: true, }, ] @@ -1,2 +1,2 @@ export * from './use-model' -export * from './use-chat-links' +export * from './use-chat-links' \ No newline at end of file @@ -10,7 +10,7 @@ export const useNavigationSearchChatLinks = () => { const { data } = useSession() const fetchChatLinks = async () => { - if (!data) return + if(!data) return setChatLinks((await getModelChatLinks(data.access)).data) } @@ -21,6 +21,7 @@ export const useNavigationSearchChatLinks = () => { label: el.title, url: 'chat-bot/' + el.slug, category: 'Чат-боты', + alternative_labels: el.alternative_titles })), [chatLinks] ) @@ -21,6 +21,7 @@ export const useNavigationSearchMediaLinks = () => { label: el.title, url: 'images/' + el.slug, category: 'Изображения', + alternative_labels: el.alternative_titles })), [mediaLinks] ) @@ -14,9 +14,20 @@ export const useNavigationSearchModel = (links: NavigationSearchLink[]) => { const filteredLinks = useMemo(() => { if (!search) return links - return links.filter((el) => el.label.toLowerCase().includes(search.toLowerCase())) + return links.filter( + (el) => + el.label.toLowerCase().includes(search.toLowerCase()) || + (el.alternative_labels && + el.alternative_labels.some((el) => + el.toLowerCase().includes(search.toLowerCase()) + )) + ) }, [search, links]) + useEffect(() => { + console.log(filteredLinks) + }, [filteredLinks]) + // hooks const ctrlF = (e: KeyboardEvent) => { if (!searchRef.current) { @@ -3,20 +3,26 @@ import React from 'react' import styles from './key-for-search.module.scss' -export const KeyForSearch = () => { +interface KeyForSearchProps { + userAgent: string | null +} + +export const KeyForSearch = ({ userAgent }: KeyForSearchProps) => { + if (userAgent === null) { + return null + } + const checkType = () => { - if (navigator.userAgent && !navigator.userAgent.includes('Windows')) { - return '⌘+F' - } else { + if (userAgent.includes('Windows')) { return 'Ctrl + F' + } else { + return '⌘+F' } } return ( - - {checkType()} - + {checkType()} ) } @@ -27,10 +27,6 @@ export const Search = ({ device }: SearchProps) => { const links = useConcat(visibleChatLinks, visibleMediaLinks, staticLinks) - useEffect(() => { - console.log(links) - }, [links]) - const { search, setSearch, searchOpen, setSearchOpen, searchRef, filteredLinks } = useNavigationSearchModel(links) useEffect(() => { @@ -47,7 +43,7 @@ export const Search = ({ device }: SearchProps) => { sx={{ width: '457px' }} noOptionsText={'Не найдено'} options={filteredLinks} - // filterOptions={filter} + filterOptions={(x) => x} groupBy={(link) => link.category} renderGroup={(params) => (
  • @@ -132,7 +128,7 @@ export const Search = ({ device }: SearchProps) => { }} /> ), - endAdornment: , + endAdornment: , }} /> )} @@ -1,2 +1,2 @@ export * from './ui' -export * from './config' +export * from './config' \ No newline at end of file @@ -1,6 +1,15 @@ import React, { useEffect, useMemo, useState } from 'react' import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import { Accordion, AccordionDetails, AccordionSummary, Box, Button, MenuItem, Stack, Typography } from '@mui/material' +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Button, + MenuItem, + Stack, + Typography, +} from '@mui/material' import Router, { useRouter } from 'next/router' import { useSession } from 'next-auth/react' @@ -18,6 +27,8 @@ export interface IOffer { tokens_per_plan: string title: string duration: string + accessed_models: string[] + points: string[] } interface IPaymentsProps { @@ -69,51 +80,49 @@ export const Payment: React.FC = ({ device, changeClose }) {offers?.map((offer) => { return ( setSelectedOffer(uid)} - uid={offer.uid} - price={offer.price} - tokens_per_plan={offer.tokens_per_plan} key={offer.uid} /> ) })} - {messages && ( - - - История - {messages.map((el) => ( - - ))} - - - )} + {/* {messages && ( + + + История + {messages.map((el) => ( + + ))} + + + )} */} @@ -121,7 +130,10 @@ export const Payment: React.FC = ({ device, changeClose }) {info.map((el) => { return ( - + p': { @@ -0,0 +1,98 @@ +.tarif { + background-color: var(--new-ui-main-color); + padding: 30px; + border-radius: 15px; + width: 100%; + display: flex; + text-align: left; + flex-direction: column; + position: relative; + &__btn{ + transition: all 0.8s ease-in-out !important; + } + &::before { + content: ''; + position: absolute; + top: 0; + + visibility: hidden; + left: 0; + width: 100%; + height: 100%; + border: 2px solid var(--air-color); + border-radius: 15px; + transition: all 0.6s ease-in-out; + opacity: 0; + } + &:hover { + &::before { + opacity: 1; + visibility: visible; + } + .tarif__btn { + background-color: var(--air-color); + color: white; + border: unset; + } + } + &__header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 45px; + position: relative; + z-index: 1; + } + + &__badge { + display: flex; + align-items: center; + gap: 5px; + width: max-content; + span { + font-size: 36px; + font-weight: 700; + } + } + + &__quote { + font-size: 18px; + font-weight: 700; + color: var(--new-ui-gray-color); + } + + &__price { + font-weight: 700; + font-size: 25px; + letter-spacing: -2%; + margin-bottom: 8px; + position: relative; + z-index: 1; + } + + &__list { + display: flex; + flex-direction: column; + align-items: flex-start; + padding-left: 20px; + gap: 4px; + margin-bottom: 45px; + position: relative; + z-index: 1; + } + + &__btn { + border-color: var(--air-color); + color: var(--air-color); + position: relative; + z-index: 1; + } +} + +.point { + color: var(--new-ui-text-color); + text-align: left; + &::marker { + color: var(--new-ui-gray-color) !important; + } +} @@ -1,11 +1,11 @@ import React from 'react' -import { Box, Typography } from '@mui/material' -import Button from '@mui/material/Button' -import { Balance } from '#/shared' -import { useThemeAndDevice } from '#/shared/lib/hooks' import { IOffer } from '#/widgets/payment/model/payment' -import styles from '#/widgets/payment/ui/payment.module.scss' +import styles from './offer.module.scss' +import { CommonBagde } from '#/shared/ui/badge' +import LightningSvg from '#/assets/svg/lightning.svg?react' +import { CommonButton } from '#/shared/ui/button' +import { c, commaSeparated } from '#/shared/lib/helpers' interface IOfferProps extends IOffer { pickOffer: (uid: string) => void @@ -14,42 +14,34 @@ interface IOfferProps extends IOffer { pay: (uid: string) => void } -const duration = { - month: 'мес', - year: 'год', -} +const Offer: React.FC = ({ pickOffer, uid, tokens_per_plan, price, points, pay }) => { -const Offer: React.FC = ({ - pickOffer, - uid, - tokens_per_plan, - price, - selectedOffer, - title, - duration: dur, - pay, -}) => { return ( - pickOffer(uid)}> - {title} - - - {Math.floor(+price)} ₽  - - - -
      -
    • - {Math.floor(+tokens_per_plan)} токенов +
      pickOffer(uid)}> +
      + + + + {commaSeparated(Math.round(Number(tokens_per_plan)))} + + +

      токенов

      +
      + +
        + {points.map((el, idx) => ( +
      • + {el}
      • -
      - - - - - + ))} +
    + +

    {commaSeparated(parseInt(price) ?? 0)}₽

    + + pay(uid)} variant={'outline'}> + Оплатить + +
  • ) } @@ -43,30 +43,9 @@ } } .messages { - margin-left: 10px; - width: 18%; - padding-top: 10px; @media (max-width: 768px) { margin-top: 15px; margin-left: 0; width: 100%; } } -.tarif { - position: relative; - width: 326px; - height: 380px; - margin-right: 20px; - background-color: var(--new-ui-main-color); - border-radius: 15px; - text-align: center; - border: 2px solid rgba(130, 128, 255, 0.4); - padding: 30px; - @media (max-width: 768px) { - width: 94vw; - - height: 300px; - margin: 0; - margin-top: 10px; - } -} @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('../sentry.server.config'); + } +} @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { getToken } from 'next-auth/jwt' + +// export async function middleware(req) { +// const res = NextResponse.next() + +// const token = await getToken({ req }) + +// if (!token) { +// const url = req.nextUrl.clone() +// url.pathname = '/login' +// const response = NextResponse.redirect(url) + +// req.url +// .split('?')[1] +// ?.split('&') +// .forEach((el) => { +// const [key, value] = el.split('=') +// response.cookies.set(key, value) +// }) + +// return response +// } + +// return res +// } + +const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET) + +export async function middleware(req) { + const token = await getToken({ req }) + if (!token) { + return redirectToLogin(req) + } + + return NextResponse.next() +} + +function redirectToLogin(req) { + const url = req.nextUrl.clone() + url.pathname = '/login' + const response = NextResponse.redirect(url) + + // Убедитесь, что query-параметры правильно передаются + const queryParams = req.nextUrl.searchParams + queryParams.forEach((value, key) => { + response.cookies.set(key, value) + }) + + return response +} + +export const config = { + matcher: [ + '/', + '/account', + '/admin', + '/service-keys', + '/chat-bot/:path*', + '/chat-bot/:path*', + '/dalle', + '/kandinsky', + '/stable-diffusion', + '/midjourney', + '/whisper', + '/business/', + ], +} @@ -6,6 +6,7 @@ "quotes": ["warn", "single"], "indent": "off", "jsx-quotes": ["warn", "prefer-single"], + "react/display-name": "off", "simple-import-sort/imports": [ "warn", { @@ -25,7 +26,6 @@ ] } ], - "simple-import-sort/exports": "warn", - "react/display-name": "warn" + "simple-import-sort/exports": "warn" } } @@ -38,3 +38,6 @@ next-env.d.ts /.idea + +# Sentry Config File +.env.sentry-build-plugin @@ -1,84 +1,86 @@ stages: - - Build - - Deploy + - Build + - Deploy default: - image: docker:rc-cli - before_script: - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin + image: docker:latest + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin build_staging: - stage: Build - script: - - cp $ENV .env.production - - docker compose --env-file $ENV build - - docker compose push - environment: - name: staging - url: https://staging.air.fail - services: - - docker:dind - only: - - staging + stage: Build + image: docker:cli + script: + - cp $ENV .env.production + - docker compose --env-file $ENV build + - docker compose push + environment: + name: staging + url: https://staging.air.fail + services: + - docker:dind + only: + - staging deploy_staging: - stage: Deploy - variables: - DOCKER_HOST: tcp://$STAGING_CLUSTER_HOST:2376 - DOCKER_TLS_VERIFY: 1 - DOCKER_CERT_PATH: '/certs' - services: - - docker:dind - environment: - name: staging - url: https://staging.air.fail - only: - - staging - before_script: - - cp $ENV .env.production - - mkdir -p $DOCKER_CERT_PATH - - echo "$STAGING_CLUSTER_CA" > $DOCKER_CERT_PATH/ca.pem - - echo "$STAGING_CLUSTER_CERT" > $DOCKER_CERT_PATH/cert.pem - - echo "$STAGING_CLUSTER_KEY" > $DOCKER_CERT_PATH/key.pem - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin - script: - - docker compose pull - - docker compose --env-file $ENV --project-name air-frontend up -d + stage: Deploy + image: docker:cli + variables: + DOCKER_HOST: tcp://$STAGING_CLUSTER_HOST:2376 + DOCKER_TLS_VERIFY: 1 + DOCKER_CERT_PATH: "/certs" + services: + - docker:dind + environment: + name: staging + url: https://staging.air.fail + only: + - staging + before_script: + - cp $ENV .env.production + - mkdir -p $DOCKER_CERT_PATH + - echo "$STAGING_CLUSTER_CA" > $DOCKER_CERT_PATH/ca.pem + - echo "$STAGING_CLUSTER_CERT" > $DOCKER_CERT_PATH/cert.pem + - echo "$STAGING_CLUSTER_KEY" > $DOCKER_CERT_PATH/key.pem + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin + script: + - docker compose pull + - docker compose --env-file $ENV --project-name air-ui-web up -d build_production: - stage: Build - script: - - cp $ENV .env.production - - docker compose --env-file $ENV build - - docker compose push - environment: - name: production - url: https://app.air.fail - services: - - docker:dind - only: - - main + stage: Build + script: + - cp $ENV .env.production + - docker compose -f stack.yml --env-file $ENV build + - docker compose -f stack.yml push + environment: + name: production + url: https://app.air.fail + services: + - docker:dind + only: + - main deploy_production: - stage: Deploy - variables: - DOCKER_HOST: tcp://$PRODUCTION_CLUSTER_HOST:2376 - DOCKER_TLS_VERIFY: 1 - DOCKER_CERT_PATH: '/certs' - services: - - docker:dind - environment: - name: production - url: https://app.air.fail - only: - - main - before_script: - - cp $ENV .env.production - - mkdir -p $DOCKER_CERT_PATH - - echo "$PRODUCTION_CLUSTER_CA" > $DOCKER_CERT_PATH/ca.pem - - echo "$PRODUCTION_CLUSTER_CERT" > $DOCKER_CERT_PATH/cert.pem - - echo "$PRODUCTION_CLUSTER_KEY" > $DOCKER_CERT_PATH/key.pem - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin - script: - - docker compose pull - - docker compose --env-file $ENV --project-name air-frontend up -d + stage: Deploy + image: docker:cli + variables: + DOCKER_HOST: tcp://$PRODUCTION_CLUSTER_HOST:2376 + DOCKER_TLS_VERIFY: 1 + DOCKER_CERT_PATH: "/certs" + services: + - docker:dind + environment: + name: production + url: https://app.air.fail + only: + - main + before_script: + - cp $ENV .env.production + - mkdir -p $DOCKER_CERT_PATH + - echo "$PRODUCTION_CLUSTER_CA" > $DOCKER_CERT_PATH/ca.pem + - echo "$PRODUCTION_CLUSTER_CERT" > $DOCKER_CERT_PATH/cert.pem + - echo "$PRODUCTION_CLUSTER_KEY" > $DOCKER_CERT_PATH/key.pem + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" $CI_REGISTRY --password-stdin + script: + - docker stack deploy --prune --with-registry-auth --resolve-image=always --compose-file stack.yml --detach frontend \ No newline at end of file @@ -1,10 +1,10 @@ { - "useTabs": true, - "tabWidth": 5, - "singleQuote": true, - "trailingComma": "es5", - "jsxBracketSameLine": false, - "semi": false, - "printWidth": 120, - "jsxSingleQuote": true + "useTabs": true, + "tabWidth": 5, + "singleQuote": true, + "trailingComma": "es5", + "jsxBracketSameLine": false, + "semi": false, + "printWidth": 100, + "jsxSingleQuote": true } @@ -2,7 +2,7 @@ FROM node:alpine as dependencies WORKDIR /app COPY package.json ./package.json -RUN npm install +RUN --mount=type=cache,target=/root/.npm npm install FROM node:alpine as builder @@ -1,17 +1,17 @@ services: - app: - build: - context: . - dockerfile: Dockerfile.dev - container_name: frontend - restart: unless-stopped - volumes: - - .:/app - ports: - - '3000:3000' - env_file: - - .env + app: + build: + context: . + dockerfile: Dockerfile.dev + container_name: frontend + restart: unless-stopped + volumes: + - .:/app + ports: + - '3000:3000' + env_file: + - .env networks: - default: - name: 'air' + default: + name: 'air' @@ -1,17 +1,27 @@ services: - app: - image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - build: - context: . - dockerfile: Dockerfile - container_name: frontend - restart: unless-stopped - ports: - - '3000:3000' - env_file: - - .env.production + app: + image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + networks: + - infrastructure + env_file: + - .env.production + labels: + - traefik.enable=true + - traefik.docker.network=infrastructure + - traefik.http.routers.frontend.rule=Host(`$DOMAIN`) + - traefik.http.routers.frontend.entrypoints=web,websecure + - traefik.http.routers.frontend.tls=true + - traefik.http.routers.frontend.tls.certresolver=defaultresolver + - traefik.http.routers.frontend.service=frontend + - traefik.http.services.frontend.loadbalancer.server.port=3000 + - traefik.http.middlewares.frontend.redirectscheme.scheme=https + - traefik.http.middlewares.frontend.redirectscheme.permanent=true networks: - default: - name: 'air' - external: true + infrastructure: + name: infrastructure + external: true @@ -32,13 +32,8 @@ const nextConfig = { reactStrictMode: false, images: { domains: [ - '195.93.252.114', 'app.air.fail', - 'air_django', - '79.133.181.83', - 'images.air.fail', 'storage.yandexcloud.net', - 'dev.air.fail', 's3-alpha-sig.figma.com', 'localhost', ], @@ -50,3 +45,21 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ }) module.exports = withBundleAnalyzer(nextConfig) + + +// Injected content via Sentry wizard below + +const { withSentryConfig } = require("@sentry/nextjs"); + +module.exports = withSentryConfig( + module.exports, + { + org: process.env.NEXT_PUBLIC_SENTRY_ORGANIZATION || '', + sentryUrl: `${process.env.NEXT_PUBLIC_SENTRY_DOMAIN ? process.env.NEXT_PUBLIC_SENTRY_DOMAIN + '/' : ''}`, + widenClientFileUpload: true, + tunnelRoute: '/monitoring', + disableLogger: true, + project: process.env.NEXT_PUBLIC_SENTRY_PROJECT || '', + authToken: process.env.NEXT_PUBLIC_SENTRY_AUTH_TOKEN || '' + } +); @@ -0,0 +1,15 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || '', + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); @@ -1,11 +0,0 @@ -import * as Sentry from '@sentry/nextjs' - -Sentry.init({ - dsn: 'https://977e4e013d7844c49c1c642cca0dff8f@o4505483299979264.ingest.sentry.io/4505550927691776', - integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()], - // Performance Monitoring - tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! - // Session Replay - replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. - replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. -}) @@ -0,0 +1,16 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || '', + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); @@ -0,0 +1,15 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || '', + + // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. + tracesSampleRate: 1, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}); @@ -0,0 +1,47 @@ +services: + app: + image: $CI_REGISTRY_IMAGE:latest + build: + context: . + dockerfile: Dockerfile + tags: + - "$CI_REGISTRY_IMAGE:latest" + - "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" + cache_from: + - type=registry,ref=$CI_REGISTRY_IMAGE/cache,ignore-error=true + cache_to: + - type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max,ignore-error=true + networks: + - infrastructure + deploy: + replicas: 2 + update_config: + parallelism: 1 + delay: 10s + order: start-first + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + window: 30s + placement: + constraints: + - node.role == worker + labels: + - traefik.enable=true + - traefik.docker.network=infrastructure + - traefik.http.routers.frontend.rule=Host(`$DOMAIN`) + - traefik.http.routers.frontend.entrypoints=web,websecure + - traefik.http.routers.frontend.tls=true + - traefik.http.routers.frontend.tls.certresolver=defaultresolver + - traefik.http.routers.frontend.service=frontend + - traefik.http.services.frontend.loadbalancer.server.port=3000 + - traefik.http.middlewares.frontend.redirectscheme.scheme=https + - traefik.http.middlewares.frontend.redirectscheme.permanent=true + env_file: + - .env.production + +networks: + infrastructure: + name: infrastructure + external: true @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "ESNext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -18,7 +18,7 @@ "noImplicitThis": false, "paths": { "#/*": ["./src/*"], - "@/components/*": ["components/*"] + "#/components/*": ["components/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], @@ -1,9 +1,9 @@ { - "static_path": ".next", - "app_id": 51767823, - "endpoints": { - "mobile": "/", - "mvk": "/", - "web": "/" - } -} + "static_path": ".next", + "app_id": 51767823, + "endpoints": { + "mobile": "/", + "mvk": "/", + "web": "/" + } +} \ No newline at end of file