@@ -1,9 +1,35 @@ import axios from 'axios' -import { Message, MessageSend } from '../types' +import { MediaGenerationsPage, MediaStore, Message, MessageSend } from '../types' import { getApiUrl } from '#/shared/lib/constants' +export async function getMediaGenerations( + token: string, + options: { + store: MediaStore + page_size?: number + cursor?: string | null + } +) { + const params = new URLSearchParams({ store: options.store }) + + if (options.page_size != null) { + params.set('page_size', String(options.page_size)) + } + + if (options.cursor) { + params.set('cursor', options.cursor) + } + + return await axios.get(`${getApiUrl()}/api/media/generations/?${params.toString()}`, { + validateStatus: (status) => status < 500, + headers: { + Authorization: `Bearer ${token}`, + }, + }) +} + export async function sendMediaMessage( model: string | null, modelType: 'video' | 'image' | 'audio' | 'voice', @@ -4,6 +4,14 @@ export interface MessageSend { info: T } +export type MediaStore = 'image' | 'video' | 'audio' | 'voice_clone' + +export interface MediaGenerationsPage { + next: string | null + previous: string | null + results: Message[] +} + export interface Message { content: string created_at: string @@ -1,94 +1,106 @@ -import React from 'react' -import { Stack } from '@mui/material' - -import { CheckboxFilter } from '#/app/components/filters/checkbox_filter' -import { InputFilter } from '#/app/components/filters/input_filter' -import { SelectFilter } from '#/app/components/filters/select_filter' -import { Slide } from '#/app/components/filters/slide_filter' -import { setParams } from '#/app/store/model-parametres-store' -import { useAppDispatch, useAppSelector } from '#/app/store/store' -import { IModelParams } from '#/shared/api/models/models' - -import { usePruneModelParams } from './model/use-prune-model-params' - -interface IProps { - params: IModelParams[] - currentVersion: string -} -export default function BotParamsMap({ params, currentVersion }: IProps) { - const includeParams = useAppSelector((state) => state.params.params) - const dispatch = useAppDispatch() - - usePruneModelParams(params, currentVersion) - - const setNewParam = React.useCallback( - (payload: { [key: string]: string | number | number[] | boolean }) => { - dispatch(setParams(payload)) - }, - [dispatch] - ) - - return ( - - {params.map((item, idx) => { - if (item.versions.length === 0 || item.versions.includes(currentVersion)) { - if (item.type == 'floatrange' || item.type == 'intrange') { - return ( - - ) - } - if (item.type == 'bool') { - return ( - - ) - } - - if (item.type == 'list') { - return ( - - ) - } - - if (item.type == 'int' || item.type == 'str') { - return ( - - ) - } - } - })} - - ) -} +import React from 'react' +import { Stack } from '@mui/material' + +import { CheckboxFilter } from '#/app/components/filters/checkbox_filter' +import { InputFilter } from '#/app/components/filters/input_filter' +import { SelectFilter } from '#/app/components/filters/select_filter' +import { Slide } from '#/app/components/filters/slide_filter' +import { setParams } from '#/app/store/model-parametres-store' +import { useAppDispatch, useAppSelector } from '#/app/store/store' +import { IModelParams } from '#/shared/api/models/models' + +import { usePruneModelParams } from './model/use-prune-model-params' + +interface IProps { + params: IModelParams[] + currentVersion: string + layout?: 'rows' | 'grid' +} +export default function BotParamsMap({ params, currentVersion, layout = 'rows' }: IProps) { + const includeParams = useAppSelector((state) => state.params.params) + const dispatch = useAppDispatch() + + usePruneModelParams(params, currentVersion) + + const setNewParam = React.useCallback( + (payload: { [key: string]: string | number | number[] | boolean }) => { + dispatch(setParams(payload)) + }, + [dispatch] + ) + + return ( + + {params.map((item, idx) => { + if (item.versions.length === 0 || item.versions.includes(currentVersion)) { + if (item.type == 'floatrange' || item.type == 'intrange') { + return ( + + ) + } + if (item.type == 'bool') { + return ( + + ) + } + + if (item.type == 'list') { + return ( + + ) + } + + if (item.type == 'int' || item.type == 'str') { + return ( + + ) + } + } + })} + + ) +} \ No newline at end of file @@ -0,0 +1 @@ +export * from './use-media-generations-pagination' @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useState } from 'react' +import { useSession } from 'next-auth/react' + +import { getMediaGenerations, MediaStore, Message } from '#/entities/message' +import { getApiUrl } from '#/shared/lib/constants' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +const PAGE_SIZE = 20 + +function extractCursor(nextUrl: string | null): string | null { + if (!nextUrl) { + return null + } + + try { + const url = new URL(nextUrl, getApiUrl()) + return url.searchParams.get('cursor') + } catch { + return null + } +} + +function appendUniqueByUid(prev: Message[], incoming: Message[]): Message[] { + const seen = new Set(prev.map((message) => message.uid)) + const additions = incoming.filter((message) => !seen.has(message.uid)) + + if (additions.length === 0) { + return prev + } + + return [...prev, ...additions] +} + +export function useMediaGenerationsPagination(store: MediaStore) { + const { data } = useSession() + const { showMessage } = useShowDataStore() + + const [items, setItems] = useState(null) + const [loading, setLoading] = useState(true) + const [paginationLoading, setPaginationLoading] = useState(false) + const [cursor, setCursor] = useState(null) + const [hasMore, setHasMore] = useState(true) + + useEffect(() => { + if (!data?.access) { + return + } + + setItems(null) + setCursor(null) + setHasMore(true) + + let cancelled = false + + ;(async () => { + setLoading(true) + + try { + const { data: page, status } = await getMediaGenerations(data.access, { + store, + page_size: PAGE_SIZE, + }) + + if (cancelled) { + return + } + + if (status >= 400 || !Array.isArray(page?.results)) { + showMessage('Ошибка загрузки') + setItems([]) + setHasMore(false) + return + } + + const nextCursor = extractCursor(page.next) + setItems(page.results) + setCursor(nextCursor) + setHasMore(Boolean(nextCursor)) + } catch (error) { + console.error('[useMediaGenerationsPagination] initial load failed', { store, error }) + if (!cancelled) { + showMessage('Ошибка загрузки') + setItems([]) + setHasMore(false) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + })() + + return () => { + cancelled = true + } + }, [data?.access, store, showMessage]) + + const loadMore = useCallback(async () => { + if (!data?.access || paginationLoading || loading || !hasMore || !cursor) { + return + } + + setPaginationLoading(true) + + try { + const { data: page, status } = await getMediaGenerations(data.access, { + store, + page_size: PAGE_SIZE, + cursor, + }) + + if (status >= 400 || !Array.isArray(page?.results) || page.results.length === 0) { + setHasMore(false) + return + } + + const nextCursor = extractCursor(page.next) + setItems((prev) => (prev ? appendUniqueByUid(prev, page.results) : page.results)) + setCursor(nextCursor) + setHasMore(Boolean(nextCursor)) + } catch (error) { + console.error('[useMediaGenerationsPagination] loadMore failed', { store, error }) + showMessage('Ошибка загрузки') + } finally { + setPaginationLoading(false) + } + }, [data?.access, paginationLoading, loading, hasMore, cursor, store, showMessage]) + + return { + items, + loading, + paginationLoading, + hasMore, + loadMore, + setItems, + } +} @@ -0,0 +1 @@ +export * from './model' @@ -8,6 +8,17 @@ border: 2px solid rgb(66, 66, 72) !important; } +.wrapperStacked { + flex-direction: column; + align-items: stretch; + padding: 16px 18px 14px; + border-radius: 28px; + gap: 12px; + border: 1px solid rgba(66, 66, 72, 0.9) !important; + background: rgba(21, 21, 24, 0.92); + backdrop-filter: blur(10px); +} + .wrapperImages { background-color: #151518; } @@ -19,6 +30,8 @@ .settingsIcon { min-width: 21px; min-height: 21px; + flex-shrink: 0; + cursor: pointer; } .area { @@ -31,6 +44,8 @@ padding: 0px; font-size: 16px; max-height: 82px; + background: transparent; + color: #ffffff; &::-webkit-scrollbar { display: none; /* Chrome, Brave, Edge */ @@ -38,6 +53,7 @@ &::placeholder { font-size: 16px; + color: #8b919c; } border: none; @@ -47,6 +63,13 @@ } } +.areaStacked { + max-height: 120px; + min-height: 48px; + line-height: 1.4; + color: #ffffff; +} + .endAdornment { position: relative; display: flex; @@ -54,6 +77,39 @@ justify-content: center; } +.endAdornmentStacked { + width: 100%; + justify-content: space-between; + align-items: center; +} + +.endActions { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.paramsBtn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: none; + border-radius: 999px; + background: rgba(127, 125, 243, 0.18); + color: #7f7df3; + font: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; + + &:hover { + background: rgba(127, 125, 243, 0.26); + } +} + .divider { height: 20px; width: 1px; @@ -68,7 +124,7 @@ padding: 4px 14px; border-radius: 16px; margin-right: 8px; - background-color: #7F7DF31A; + background-color: #7f7df31a; gap: 4px; @media screen and (max-width: 400px) { @@ -81,8 +137,3 @@ .generationIcon { margin-top: 3px; } - -.settingsIcon { - flex-shrink: 0; - cursor: pointer; -} @@ -62,6 +62,11 @@ interface Input { value?: string onValueChange?: (value: string) => void predictedPrice?: string | null + /** media: textarea сверху, кнопки снизу */ + layout?: 'inline' | 'stacked' + showParamsButton?: boolean + onOpenParams?: () => void + placeholder?: string } export const ModelInput: FC = ({ @@ -80,6 +85,10 @@ export const ModelInput: FC = ({ value: externalValue, onValueChange: externalOnChange, predictedPrice, + layout = 'inline', + showParamsButton = false, + onOpenParams, + placeholder, }: Input) => { const [disabled, setDisabled] = React.useState(true) const [required, setRequired] = React.useState<(string | null)[]>([]) @@ -231,100 +240,135 @@ export const ModelInput: FC = ({ return () => window.removeEventListener('tour-send-message', handleTourSendMessage) }, [clearDraft, sendMessage, unpinImage]) - return ( - <> -
onOpenParams?.()} > - {!desktop && ( - - - - )} - - -
- + + + + Параметры + + ) : null + + const controls = ( +
+ {isStacked && paramsButton} +
+ {hasAttachInput && ( - <> - {!blocked && ( - - )} -
- - )} + <> + {!blocked && ( + + )} + {!isStacked &&
} + + )} {!disabled && !blocked && ( string)) => { - const newVal = typeof val === 'function' ? val(value) : val - externalOnChange(newVal) - } : - (val: string | ((prev: string) => string)) => { - const next = typeof val === 'function' ? val(value) : val - if (next === '') { - clearDraft() - } else { - setInternalValue(next) - } - } + setInput={ + externalOnChange + ? (val: string | ((prev: string) => string)) => { + const newVal = typeof val === 'function' ? val(value) : val + externalOnChange(newVal) + } + : (val: string | ((prev: string) => string)) => { + const next = typeof val === 'function' ? val(value) : val + if (next === '') { + clearDraft() + } else { + setInternalValue(next) + } + } } required={required} /> )}
+ ) + + return ( + <> +
+ {!desktop && !isStacked && ( + + + + )} + + + {controls} +
- {styles === 'chats' && ( - - )} + {styles === 'chats' && } ) } @@ -0,0 +1,6 @@ +import { MediaPage } from '#/views/media' +import { getDefaultLayout } from '#/widgets/layouts' + +MediaPage.getLayout = getDefaultLayout({ titlePage: 'Медиа', darkSurface: true }) + +export default MediaPage @@ -0,0 +1,2 @@ +export { default } from './media-page' +export { MediaPage } from './media-page' @@ -0,0 +1,48 @@ +.page { + position: relative; + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.selectorOverlay { + position: absolute; + top: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 12; + pointer-events: none; + + > * { + pointer-events: auto; + } +} + +.gridScroll { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 160px; +} + +.inputBar { + position: fixed; + left: 130px; + right: 80px; + bottom: 24px; + z-index: 20; + max-width: 920px; + width: calc(100% - 210px); + margin: 0 auto; + + @media (max-width: 766px) { + left: 12px; + right: 12px; + bottom: 16px; + width: auto; + } +} @@ -0,0 +1,186 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useSession } from 'next-auth/react' + +import { useAppSelector } from '#/app/store/store' +import { IShortModel } from '#/entities/model-entity' +import { useImageBot } from '#/entities/model-entity/model/use-image-bot' +import { useCreateMediaMessage } from '#/features/create-media-message' +import { useImagesBotFilters } from '#/features/image-bot-filters' +import { useImagesUniqInput } from '#/features/image-bot-input' +import { useMediaGenerationsPagination } from '#/features/media-generations-pagination' +import { ModelInput } from '#/features/model-input' +import { usePredictPrice } from '#/features/predict-price/model/use-predict-price' +import { NextPageWithLayout } from '#/pages/_app' +import model_api from '#/shared/api/models/api' +import { getDeviceType, getOs } from '#/shared/lib/helpers' +import { useDeviceType } from '#/shared/lib/hooks' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +import { MediaPageContentPaginated } from '../media-page-content-paginated' +import { MediaParamsPopover } from '../media-params-popover' +import { ModelSelector } from '../model-selector' + +import styles from './media-page.module.scss' + +const MEDIA_MODEL_SLUG_KEY = 'media-selected-image-model' + +export const MediaPage: NextPageWithLayout = () => { + const deviceOs = getOs() + const deviceType = getDeviceType() + const { desktop } = useDeviceType(deviceType, deviceOs) + const { data: session } = useSession() + const { showMessage } = useShowDataStore() + const payment_plan = useAppSelector((state) => state.user.payment_plan) + const scrollContainerRef = useRef(null) + const inputBarRef = useRef(null) + + const [models, setModels] = useState([]) + const [selectedSlug, setSelectedSlug] = useState('') + const [prompt, setPrompt] = useState('') + const [paramsOpen, setParamsOpen] = useState(false) + + const { botParams, version, modelType, fetchBotParams, resetParams } = useImageBot(selectedSlug) + const { includeParams } = useImagesBotFilters() + const { items, setItems, loading, paginationLoading, hasMore, loadMore } = useMediaGenerationsPagination('image') + const { createImage, createLoading } = useCreateMediaMessage( + showMessage, + modelType, + 'image', + deviceType, + setItems, + scrollContainerRef + ) + const { onCreateImage, onLoadImage, image, setImage, pinImageFromUrl } = useImagesUniqInput( + version, + includeParams, + createImage + ) + + const canAttachFile = useMemo(() => { + if (!botParams?.inputs) return false + + return botParams.inputs.some((input) => { + if (input.type === 'text') return false + if (input.versions.length === 0) return true + if (!version) return true + return input.versions.includes(version) + }) + }, [botParams?.inputs, version]) + + const hasParams = useMemo(() => { + if (!botParams?.parameters?.length) return false + return botParams.parameters.some( + (param) => param.versions.length === 0 || !version || param.versions.includes(version) + ) + }, [botParams?.parameters, version]) + + const predictPriceInfo = useMemo( + () => ({ ...(includeParams || {}), ...(version ? { version } : {}) }), + [includeParams, version] + ) + + const predictedPrice = usePredictPrice({ + modelSlug: modelType, + content: prompt, + fileExists: !!image, + info: predictPriceInfo, + token: session?.access, + enabled: !!modelType && !!session?.access, + }) + + useEffect(() => { + if (!session?.access) return + + model_api.getImages(session.access).then((list) => { + const available = Array.isArray(list) ? list.filter((model) => !model.blocked) : [] + const all = Array.isArray(list) ? list : [] + setModels(all) + + const savedSlug = typeof window !== 'undefined' ? localStorage.getItem(MEDIA_MODEL_SLUG_KEY) : null + const initial = + (savedSlug && all.find((model) => model.slug === savedSlug)?.slug) || + available[0]?.slug || + all[0]?.slug || + '' + + setSelectedSlug(initial) + }) + }, [session?.access]) + + useEffect(() => { + if (!session?.access || !selectedSlug) return + + localStorage.setItem(MEDIA_MODEL_SLUG_KEY, selectedSlug) + void fetchBotParams() + }, [session?.access, selectedSlug]) + + const handleSelectModel = (slug: string) => { + if (slug === selectedSlug) return + setSelectedSlug(slug) + setPrompt('') + setImage(null) + } + + return ( +
+
+ +
+ +
+ +
+ +
+ {botParams && ( + <> + setImage(null)} + viewMobileSettings={() => undefined} + predictedPrice={predictedPrice} + showParamsButton={hasParams} + onOpenParams={() => setParamsOpen(true)} + /> + setParamsOpen(false)} + params={botParams.parameters || []} + currentVersion={version} + onReset={resetParams} + desktop={desktop} + /> + + )} +
+
+ ) +} + +export default MediaPage @@ -0,0 +1 @@ +export { MediaPageContentPaginated } from './media-page-content-paginated' @@ -0,0 +1,107 @@ +.root { + position: relative; + min-height: 320px; + padding: 0 80px 0 0; + border-radius: 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 4px; + width: 100%; + align-items: start; + + @media (max-width: 1200px) { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + } + + @media (max-width: 766px) { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } +} + +.tile { + position: relative; + display: block; + width: 100%; + border-radius: 4px; + overflow: hidden; + cursor: pointer; + background: #2d2d2f; +} + +.tileZip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 180px; + border: 1px solid #8280ff; + cursor: default; + padding: 12px; + text-align: center; +} + +.image { + display: block; + width: 100%; + height: auto; + border-radius: 4px; + user-select: none; + vertical-align: top; +} + +.imageVisible { + opacity: 1; +} + +.imageHidden { + opacity: 0; + position: absolute; +} + +.skeleton { + display: block; + width: 100%; + min-height: 200px; + border-radius: 4px; +} + +.model { + position: absolute; + top: 12px; + left: 12px; + z-index: 2; + background-color: rgba(#151518, 0.5); + font-size: 13px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; + color: #fff; +} + +.empty { + color: #a4aab5; + font-size: 15px; + text-align: center; + padding: 40px 0; +} + +.loader { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +} + +.paginationLoader { + display: flex; + justify-content: center; + padding-top: 20px; +} + +.sentinel { + height: 1px; + width: 100%; +} @@ -0,0 +1,174 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { Box, CircularProgress, Skeleton, Typography } from '@mui/material' +import Image from 'next/image' +import Link from 'next/link' + +import { ImageModal } from '#/features/image-modal' +import { ClientOnly, Loader } from '#/shared' +import { c } from '#/shared/lib/helpers' +import { ImageIcons, useMessages } from '#/widgets/messages' + +import styles from './media-page-content-paginated.module.scss' +import { MediaPageContentPaginatedProps } from './types' + +export const MediaPageContentPaginated = ({ + items, + loading, + paginationLoading, + hasMore, + loadMore, + canAttachFile = false, + onPinImageFromUrl, + scrollRootRef, +}: MediaPageContentPaginatedProps) => { + const paginationSentinelRef = useRef(null) + const [modal, setModal] = useState(false) + const [loadedMap, setLoadedMap] = useState>({}) + const { chosenImage, setChosenImage, computedLibraryImages } = useMessages(items ?? []) + + useEffect(() => { + if (!hasMore || !items?.length || loading) { + return + } + + const sentinel = paginationSentinelRef.current + if (!sentinel) { + return + } + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0]?.isIntersecting || paginationLoading || !hasMore) { + return + } + + void loadMore() + }, + { + root: scrollRootRef?.current ?? null, + rootMargin: '400px', + } + ) + + observer.observe(sentinel) + + return () => observer.disconnect() + }, [hasMore, items?.length, loading, loadMore, paginationLoading, scrollRootRef]) + + const openModal = (file: string) => { + setChosenImage(file) + setModal(true) + } + + return ( + + + {createPortal( + { + if (hasMore) { + void loadMore() + } + }} + setModal={setModal} + reverse + current={chosenImage} + />, + document.getElementById('modal-container')! + )} + + + {loading ? ( + + + + ) : items?.length === 0 ? ( + Пока нет генераций + ) : ( + <> +
+ {items?.map((message) => { + const file = message.file?.toString() ?? '' + const isZip = file.includes('.zip') + const isLoaded = Boolean(loadedMap[message.uid]) + + if (isZip) { + return ( +
+ Эта генерация является архивом + + Скачать + +
+ ) + } + + return ( +
+ {file && ( + {message.content openModal(file)} + onLoadingComplete={() => { + setLoadedMap((prev) => ({ ...prev, [message.uid]: true })) + }} + /> + )} + + {!isLoaded && ( + + )} + + {message.model && !(canAttachFile && file) && ( + {message.model} + )} + + onPinImageFromUrl(file) + : undefined + } + /> +
+ ) + })} +
+ + {hasMore && ( + <> + {paginationLoading && ( + + + + )} +
+ + )} + + )} + + ) +} @@ -0,0 +1,13 @@ +import { Message } from '#/entities/message' +import { RefObject } from 'react' + +export interface MediaPageContentPaginatedProps { + items: Message[] | null + loading: boolean + paginationLoading: boolean + hasMore: boolean + loadMore: () => Promise + canAttachFile?: boolean + onPinImageFromUrl?: (url: string) => void + scrollRootRef?: RefObject +} @@ -0,0 +1 @@ +export { MediaParamsPopover } from './media-params-popover' @@ -0,0 +1,51 @@ +.paper { + translate: 0 -12px; + border-radius: 20px !important; + background: rgba(28, 28, 32, 0.96) !important; + backdrop-filter: blur(12px); + color: #fff !important; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45) !important; + overflow: visible !important; + max-height: none !important; +} + +.root { + display: flex; + flex-direction: column; + padding: 18px 18px 12px; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} + +.title { + font-size: 13px !important; + font-weight: 600 !important; + letter-spacing: 0.08em; + color: #a4aab5 !important; + margin-bottom: 16px !important; +} + +.body { + width: 100%; + overflow: hidden; + min-width: 0; + + :global(.MuiStack-root) { + width: 100%; + min-width: 0; + } + + :global(.MuiSlider-root), + :global(.MuiFormControl-root), + :global(.MuiBox-root) { + max-width: 100%; + min-width: 0; + } +} + +.empty { + color: #a4aab5 !important; + font-size: 14px !important; + padding: 8px 0 16px; +} @@ -0,0 +1,62 @@ +import { Box, Popover, Typography } from '@mui/material' + +import { ResetFilters } from '#/app/components/filters/reset_filters' +import BotParamsMap from '#/features/bot-params/bot-params-map' +import { IModelParams } from '#/shared/api/models/models' + +import styles from './media-params-popover.module.scss' + +interface MediaParamsPopoverProps { + anchorEl: HTMLElement | null + open: boolean + onClose: () => void + params: IModelParams[] + currentVersion: string + onReset: () => void + desktop: boolean +} + +export const MediaParamsPopover = ({ + anchorEl, + open, + onClose, + params, + currentVersion, + onReset, + desktop, +}: MediaParamsPopoverProps) => { + return ( + +
+ ПАРАМЕТРЫ + + + {params.length > 0 ? ( + + ) : ( + Нет доступных параметров + )} + + + +
+
+ ) +} @@ -0,0 +1 @@ +export { ModelSelector } from './model-selector' @@ -0,0 +1,191 @@ +.root { + pointer-events: auto; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 10px 8px 16px; + border: none; + border-radius: 20px; + background: rgba(30, 30, 34, 0.9); + backdrop-filter: blur(8px); + color: #fff; + cursor: pointer; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); + font: inherit; +} + +.label { + color: #fff; + font-size: 20px; + font-weight: 500; + line-height: 1.2; +} + +.nameChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px 4px 12px; + border: 1px solid #7f7df3; + border-radius: 14px; + background: #7f7df3; +} + +.name { + color: #fff; + font-size: 20px; + font-weight: 600; + line-height: 1.2; +} + +.chevron { + font-size: 22px !important; + color: #fff; + transition: transform 0.15s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.menuPaper { + margin-top: 10px !important; + width: min(440px, calc(100vw - 32px)); + max-height: min(420px, 70vh) !important; + border-radius: 20px !important; + background: rgba(28, 28, 32, 0.96) !important; + backdrop-filter: blur(12px); + color: #fff !important; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45) !important; + overflow: hidden; +} + +.menuList { + max-height: min(420px, 70vh); + overflow-y: auto; + padding: 8px !important; + display: flex; + flex-direction: column; + gap: 4px; +} + +.menuItem { + display: flex !important; + align-items: flex-start !important; + justify-content: space-between !important; + gap: 12px; + padding: 14px 16px !important; + border-radius: 16px !important; + white-space: normal !important; + background: transparent !important; + opacity: 1 !important; + + &:hover { + background: rgba(127, 125, 243, 0.12) !important; + } + + &.Mui-disabled { + opacity: 1 !important; + } +} + +.menuItemSelected { + background: rgba(127, 125, 243, 0.16) !important; + + &:hover { + background: rgba(127, 125, 243, 0.2) !important; + } +} + +.menuItemMuted { + .itemTitle { + color: rgba(255, 255, 255, 0.72); + } + + .itemDescription { + color: rgba(164, 170, 181, 0.75); + } +} + +.itemBody { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + flex: 1; +} + +.itemHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.itemTitle { + font-size: 18px; + font-weight: 600; + line-height: 1.25; + color: #fff; +} + +.itemTitleSelected { + color: #7f7df3; +} + +.itemDescription { + font-size: 13px !important; + line-height: 1.4 !important; + color: #a4aab5 !important; +} + +.tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border: 1px solid #7f7df3; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + background: transparent; +} + +.tagIcon { + flex-shrink: 0; + color: inherit; +} + +.tagText { + white-space: nowrap; +} + +.itemStatus { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + min-width: 24px; + padding-top: 2px; +} + +.statusSelected { + font-size: 22px !important; + color: #7f7df3; +} + +.statusBlocked { + font-size: 22px !important; + color: #e84d4d; +} @@ -0,0 +1,144 @@ +import { useState } from 'react' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import { Menu, MenuItem, Typography } from '@mui/material' +import { useRouter } from 'next/router' + +import LockSvg from '#/assets/svg/lock.svg?react' +import { IShortModel } from '#/entities/model-entity' +import { c } from '#/shared/lib/helpers' +import { SvgIcon } from '#/shared/ui/svg' + +import styles from './model-selector.module.scss' + +interface ModelSelectorProps { + models: IShortModel[] + selectedSlug: string + onSelect: (slug: string) => void + accessedModels?: string[] | null + className?: string +} + +export const ModelSelector = ({ + models, + selectedSlug, + onSelect, + accessedModels, + className, +}: ModelSelectorProps) => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const { push } = useRouter() + + const selected = models.find((model) => model.slug === selectedSlug) + const title = selected?.title || 'Модель' + + const isLocked = (slug: string) => Boolean(accessedModels && !accessedModels.includes(slug)) + + const handleSelect = (model: IShortModel) => { + if (model.blocked) return + + if (isLocked(model.slug)) { + setAnchorEl(null) + void push('/subscription') + return + } + + onSelect(model.slug) + setAnchorEl(null) + } + + return ( +
+ + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + transformOrigin={{ vertical: 'top', horizontal: 'center' }} + slotProps={{ + paper: { + className: styles.menuPaper, + }, + }} + MenuListProps={{ + className: styles.menuList, + }} + > + {models.map((model) => { + const isSelected = model.slug === selectedSlug + const locked = isLocked(model.slug) + + return ( + handleSelect(model)} + className={c( + styles.menuItem, + isSelected && styles.menuItemSelected, + (model.blocked || locked) && styles.menuItemMuted + )} + > +
+
+ + {model.title} + + + {model.tags?.length > 0 && ( +
+ {model.tags.map((tag, index) => ( + + {tag.icon && ( + + )} + {tag.title} + + ))} +
+ )} +
+ + {model.description && ( + {model.description} + )} +
+ +
+ {isSelected && !model.blocked && !locked && ( + + )} + {locked && !model.blocked && } + {model.blocked && } +
+
+ ) + })} +
+
+ ) +} @@ -0,0 +1,3 @@ +export { default as MediaPage } from './media-page' +export { MediaPageContentPaginated } from './media-page-content-paginated' +export { ModelSelector } from './model-selector' @@ -0,0 +1 @@ +export * from './ui' @@ -7,4 +7,6 @@ export interface LayoutProps { device?: Device isLoader?: boolean height?: string + /** Фон контента и хедера #151518 без визуального разделения (медиа-галерея) */ + darkSurface?: boolean } @@ -22,7 +22,14 @@ import { LayoutProps } from '../types' import styles from './default.module.scss' -export const Layout: React.FC = ({ children, titlePage, isLoader, height, device = getDeviceType() }) => { +export const Layout: React.FC = ({ + children, + titlePage, + isLoader, + height, + device = getDeviceType(), + darkSurface = false, +}) => { const dispatch = useAppDispatch() const meStatus = useAppSelector((state) => state.user.status) @@ -121,22 +128,59 @@ export const Layout: React.FC = ({ children, titlePage, isLoader, h - + {children} ) : ( - + - {children} + {darkSurface ? ( + + {children} + + ) : ( + children + )} )} @@ -1,3 +1,4 @@ export * from './use-images-pagination' export * from './use-image-icons' export * from './use-audio-file-load' +export * from './use-messages' @@ -12,9 +12,18 @@ export interface ImageIconsProps { buttonText?: string content?: string onPinImageClick?: () => void + /** Цвет иконок в меню и на триггере. По умолчанию фиолетовый как в /images */ + accentColor?: string } -export const ImageIcons = ({ uid, url, content, buttonText, onPinImageClick }: ImageIconsProps) => { +export const ImageIcons = ({ + uid, + url, + content, + buttonText, + onPinImageClick, + accentColor = '#827FFF', +}: ImageIconsProps) => { const { toggleMenu, downloadFile, iconsMenu } = useImageIcons() return ( @@ -55,7 +64,7 @@ export const ImageIcons = ({ uid, url, content, buttonText, onPinImageClick }: I @@ -101,6 +110,7 @@ export const ImageIcons = ({ uid, url, content, buttonText, onPinImageClick }: I padding: '12px 9px', borderRadius: '60px', display: iconsMenu === uid ? 'flex' : 'none', + color: accentColor, }} > @@ -116,7 +126,7 @@ export const ImageIcons = ({ uid, url, content, buttonText, onPinImageClick }: I >