@@ -7,18 +7,25 @@ import { Message, MessageSend, sendMediaMessage } from '#/entities/message' import { Device } from '#/shared/lib/types/entities' import { formDataHelper } from '#/widgets/messages' +type CreateMediaMessageOptions = { + /** Новые генерации в конец списка + скролл вниз (как в чате). */ + pinToBottom?: boolean +} + export function useCreateMediaMessage( showError: (message: string) => void, type: string, modelType: 'video' | 'image' | 'audio' | 'voice', device: Device, setMessages: Dispatch>, - mobileScrollContainer: RefObject + mobileScrollContainer: RefObject, + options?: CreateMediaMessageOptions ) { const [isComplete, setIsComplete] = useState(false) const [createLoading, setCreateLoading] = useState(false) const { data } = useSession() const dispatch = useAppDispatch() + const pinToBottom = Boolean(options?.pinToBottom) const createImage = async (dataForSend: MessageSend) => { const { content, file } = dataForSend @@ -41,11 +48,17 @@ export function useCreateMediaMessage( dispatch(getUserBalance(data?.access)) setMessages((prev) => { - if (!prev || !prev.length) return messages + if (!prev || !prev.length) { + return pinToBottom ? [...messages].reverse() : messages + } const prevUids = new Set(prev.map((m) => m.uid)) const fresh = messages.filter((m) => !prevUids.has(m.uid)) + if (pinToBottom) { + return [...prev, ...[...fresh].reverse()] + } + if (device === 'desktop') { return [...fresh, ...prev] } @@ -54,6 +67,19 @@ export function useCreateMediaMessage( setIsComplete(true) + if (pinToBottom) { + setTimeout(() => { + const container = mobileScrollContainer.current + if (!container) return + + container.scroll({ + top: container.scrollHeight, + behavior: 'smooth', + }) + }, 500) + return + } + if (device === 'desktop') { return window.scrollTo({ top: 0, @@ -20,15 +20,20 @@ function extractCursor(nextUrl: string | null): string | null { } } -function appendUniqueByUid(prev: Message[], incoming: Message[]): Message[] { +/** API отдаёт newest-first — разворачиваем в oldest→newest (как в чате). */ +function toChronological(messages: Message[]): Message[] { + return [...messages].reverse() +} + +function prependUniqueByUid(prev: Message[], incomingOldestFirst: Message[]): Message[] { const seen = new Set(prev.map((message) => message.uid)) - const additions = incoming.filter((message) => !seen.has(message.uid)) + const additions = incomingOldestFirst.filter((message) => !seen.has(message.uid)) if (additions.length === 0) { return prev } - return [...prev, ...additions] + return [...additions, ...prev] } export function useMediaGenerationsPagination(store: MediaStore) { @@ -73,7 +78,7 @@ export function useMediaGenerationsPagination(store: MediaStore) { } const nextCursor = extractCursor(page.next) - setItems(page.results) + setItems(toChronological(page.results)) setCursor(nextCursor) setHasMore(Boolean(nextCursor)) } catch (error) { @@ -115,7 +120,8 @@ export function useMediaGenerationsPagination(store: MediaStore) { } const nextCursor = extractCursor(page.next) - setItems((prev) => (prev ? appendUniqueByUid(prev, page.results) : page.results)) + const older = toChronological(page.results) + setItems((prev) => (prev ? prependUniqueByUid(prev, older) : older)) setCursor(nextCursor) setHasMore(Boolean(nextCursor)) } catch (error) { @@ -70,6 +70,75 @@ color: #ffffff; } +.areaRow { + position: relative; + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + min-height: 0; +} + +.areaRowExpanded { + flex: 1 1 auto; + min-height: 0; +} + +.expandBtn, +.backBtn { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: #a4aab5; + cursor: pointer; + + &:hover { + color: #fff; + background: rgba(255, 255, 255, 0.06); + } +} + +.expandBtn { + margin-top: 2px; +} + +.backBtn { + align-self: flex-start; + margin: 8px 0 8px; + color: #fff; +} + +.wrapperExpanded { + position: fixed; + inset: 0; + z-index: 1200; + width: 100%; + max-width: none; + height: 100dvh; + margin: 0; + border-radius: 0 !important; + border: none !important; + background: #151518 !important; + backdrop-filter: none; + box-sizing: border-box; +} + +.areaExpanded { + flex: 1 1 auto; + max-height: none !important; + min-height: 0; + padding-left: 8px; + height: 100% !important; + overflow-y: auto; +} + .endAdornment { position: relative; display: flex; @@ -81,6 +150,7 @@ width: 100%; justify-content: space-between; align-items: center; + flex-shrink: 0; .endActions { gap: 4px; @@ -1,4 +1,5 @@ import React, { FC, useCallback, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import { TextFieldProps } from '@mui/material/TextField/TextField' import { LoadImage } from '#/app/components/input_components/load_image' @@ -95,6 +96,8 @@ export const ModelInput: FC = ({ const [types, setTypes] = React.useState([]) const [typeVersions, setTypeVersions] = React.useState({}) const [internalValue, setInternalValue] = React.useState('') + const [expanded, setExpanded] = React.useState(false) + const textareaRef = useRef(null) const { showMessage } = useShowDataStore() const hasAttachInput = typeVersions && @@ -132,12 +135,45 @@ export const ModelInput: FC = ({ const result = sendMessage(msg, req) if (result) { window.dispatchEvent(new CustomEvent('user-sent-message')) + setExpanded(false) } return result }, [sendMessage] ) + useEffect(() => { + if (desktop || layout !== 'stacked') { + setExpanded(false) + } + }, [desktop, layout]) + + useEffect(() => { + if (!expanded) { + return + } + + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + + return () => { + document.body.style.overflow = prevOverflow + } + }, [expanded]) + + useEffect(() => { + const el = textareaRef.current + if (!el) return + + if (expanded) { + el.style.height = '100%' + return + } + + el.style.height = 'auto' + el.style.height = `${el.scrollHeight}px` + }, [expanded, value]) + useEffect(() => { if (input_types) { setRequired(buildRequiredForVersion(input_types, currentVersion)) @@ -241,6 +277,7 @@ export const ModelInput: FC = ({ }, [clearDraft, sendMessage, unpinImage]) const isStacked = layout === 'stacked' + const canExpand = isStacked && !desktop const resolvedPlaceholder = placeholder ?? (disabled || blocked @@ -319,34 +356,127 @@ export const ModelInput: FC = ({ ) - return ( - <> -
- {!desktop && !isStacked && ( - + const inputShell = ( +
+ {!desktop && !isStacked && ( + + + + )} + + {canExpand && expanded && ( + + )} + + {isStacked ? ( +
+ + /> + )} - {controls} -
+ {controls} +
+ ) + return ( + <> + {expanded && typeof document !== 'undefined' ? createPortal(inputShell, document.body) : inputShell} {styles === 'chats' && } ) @@ -15,13 +15,9 @@ .card { display: grid; grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); - grid-template-rows: auto 1fr; - grid-template-areas: - 'info media' - 'actions media'; + grid-template-areas: 'side media'; align-items: stretch; column-gap: 20px; - row-gap: 12px; width: 100%; max-width: 1080px; padding: 4px; @@ -31,7 +27,6 @@ @media (max-width: 766px) { grid-template-columns: 1fr; - grid-template-rows: auto; grid-template-areas: 'info' 'media' @@ -42,82 +37,97 @@ } } -.infoTop { - grid-area: info; +.side { + grid-area: side; display: flex; flex-direction: column; - gap: 12px; min-width: 0; - padding: 22px; + /* не раздувает ряд: высота берётся от колонки с картинкой */ + height: 0; + min-height: 100%; + overflow: hidden; @media (max-width: 766px) { - padding: 0; + display: contents; } } -.metaRow { +.infoTop { display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; + flex-direction: column; + flex: 1 1 auto; + gap: 10px; + min-width: 0; + min-height: 0; + padding: 22px; + overflow: hidden; + + @media (max-width: 766px) { + grid-area: info; + flex: 0 0 auto; + padding: 0; + overflow: visible; + } } .time { + flex: 0 0 auto; color: #fff; font-size: 18px; font-weight: 700; line-height: 1; } -.avatar, -.avatarPlaceholder { - width: 36px; - height: 36px; - border-radius: 10px; - flex-shrink: 0; - object-fit: cover; -} - -.avatarPlaceholder { - background: #303035; -} - .modelBadge { display: inline-flex; + flex: 0 0 auto; align-items: center; - gap: 6px; + gap: 4px; width: fit-content; - padding: 4px 10px; - border-radius: 999px; - background: #7f7df3; - color: #fff; + padding: 4px 10px 4px 12px; + border: 1px solid #8280ff26; + border-radius: 4px; + background: #8280ff26; + color: #8280ff; font-size: 13px; font-weight: 600; } .modelIcon { font-size: 16px !important; + color: #8280ff; } .description { + flex: 1 1 auto; margin: 0; + margin-top: 12px; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; color: #a4aab5; font-size: 15px; line-height: 1.45; word-break: break-word; + + @media (max-width: 766px) { + flex: 0 1 auto; + max-height: 40vh; + } } .actions { - grid-area: actions; display: grid; + flex: 0 0 auto; grid-template-columns: 1fr 1fr; gap: 10px; - align-content: end; width: 100%; min-width: 0; padding: 0 22px 22px; + margin-top: auto; @media (max-width: 766px) { + grid-area: actions; padding: 0; margin-top: 4px; } @@ -210,7 +220,7 @@ .paginationLoader { display: flex; justify-content: center; - padding-top: 20px; + padding-bottom: 20px; } .sentinel { @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type RefObject } from 'react' +import { useRef, useState, type RefObject } from 'react' import { createPortal } from 'react-dom' import AttachmentIcon from '@mui/icons-material/Attachment' import DownloadIcon from '@mui/icons-material/Download' @@ -27,7 +27,6 @@ interface MediaListViewProps { onPinImageFromUrl?: (url: string) => void scrollRootRef?: RefObject modelTitles?: Record - avatarUrl?: string | null } export const MediaListView = ({ @@ -38,9 +37,8 @@ export const MediaListView = ({ loadMore, canAttachFile = false, onPinImageFromUrl, - scrollRootRef, + scrollRootRef: _scrollRootRef, modelTitles = {}, - avatarUrl, }: MediaListViewProps) => { const paginationSentinelRef = useRef(null) const [modal, setModal] = useState(false) @@ -48,35 +46,6 @@ export const MediaListView = ({ const { chosenImage, setChosenImage, computedLibraryImages } = useMessages(items ?? []) const { downloadFile } = useImageIcons() - 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]) - if (loading) { return ( @@ -102,13 +71,24 @@ export const MediaListView = ({ } }} setModal={setModal} - reverse + reverse={false} current={chosenImage} />, document.getElementById('modal-container')! )} + {hasMore && ( + <> +
+ {paginationLoading && ( + + + + )} + + )} +
{items?.map((message) => { const file = message.file?.toString() ?? '' @@ -116,27 +96,54 @@ export const MediaListView = ({ const relative = formatMediaRelativeTime(message.created_at) return ( -
-
-
+
+
+
{relative} - {avatarUrl ? ( - - ) : ( -
+ + {modelTitle && ( + + + {modelTitle} + )} -
- {modelTitle && ( - - - {modelTitle} - - )} +

+ {message.content?.replaceAll('"', '').trim() || 'описание отсутствует'} +

+
-

- {message.content?.replaceAll('"', '').trim() || 'описание отсутствует'} -

+
+ + + {canAttachFile && ( + + )} +
@@ -171,53 +178,10 @@ export const MediaListView = ({
Нет файла
)}
- -
- - - {canAttachFile && ( - - )} -
) })}
- - {hasMore && ( - <> - {paginationLoading && ( - - - - )} -
- - )} ) } @@ -0,0 +1 @@ +export { MediaNewViewSwitch } from './media-new-view-switch' @@ -0,0 +1,43 @@ +.root { + display: inline-flex; + align-items: center; + gap: 10px; + box-sizing: border-box; + height: 44px; + padding: 2px 12px; + border-radius: 14px; + background: rgba(30, 30, 34, 0.9); + backdrop-filter: blur(8px); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); + color: #8280ff; + cursor: pointer; + user-select: none; + pointer-events: auto; + + @media (max-width: 766px) { + gap: 6px; + min-height: 44px; + padding: 6px 8px 6px 12px; + } +} + +.icon { + font-size: 20px !important; + color: #8280ff; + + @media (max-width: 766px) { + font-size: 16px !important; + } +} + +.label { + font-size: 14px; + font-weight: 600; + line-height: 1.2; + color: #8280ff; + white-space: nowrap; + + @media (max-width: 766px) { + display: none; + } +} @@ -0,0 +1,46 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Switch from '@mui/material/Switch' + +import { c } from '#/shared/lib/helpers' + +import styles from './media-new-view-switch.module.scss' + +interface MediaNewViewSwitchProps { + checked?: boolean + disabled?: boolean + onChange: (checked: boolean) => void + className?: string +} + +const switchSx = { + '& .MuiSwitch-switchBase.Mui-checked+.MuiSwitch-track': { + backgroundColor: '#7f7df3 !important', + }, + '& .MuiSwitch-switchBase.Mui-checked': { + color: '#7f7df3 !important', + }, + '& .MuiSwitch-track': { + backgroundColor: '#40404E !important', + }, +} + +export const MediaNewViewSwitch = ({ + checked = true, + disabled = false, + onChange, + className, +}: MediaNewViewSwitchProps) => { + return ( + + ) +} @@ -11,48 +11,37 @@ .topControls { position: absolute; top: 16px; - left: 0; - right: 0; + left: 96px; + right: 96px; z-index: 12; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; pointer-events: none; @media (max-width: 766px) { - display: flex; - align-items: center; - justify-content: space-around; - padding: 0 12px; - pointer-events: none; - } -} - -.selectorOverlay { - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - pointer-events: none; - - > * { - pointer-events: auto; - } - - @media (max-width: 766px) { - position: static; - transform: none; - pointer-events: auto; + left: 12px; + right: 12px; + gap: 8px; } } +.newViewOverlay, +.selectorOverlay, .viewToggleOverlay { - position: absolute; - top: 0; - right: 96px; + position: static; pointer-events: auto; + flex-shrink: 0; +} - @media (max-width: 766px) { - position: static; - right: auto; - } +.selectorOverlay { + display: flex; + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + justify-content: center; + overflow: hidden; } .gridScroll { @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { useRouter } from 'next/router' import { useSession } from 'next-auth/react' import { useAppSelector } from '#/app/store/store' @@ -18,11 +19,13 @@ import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { MediaPageContentPaginated } from '../media-page-content-paginated' import { MediaListView } from '../media-list-view' +import { MediaNewViewSwitch } from '../media-new-view-switch' import { MediaParamsPopover } from '../media-params-popover' import { MediaViewMode, MediaViewToggle } from '../media-view-toggle' import { ModelSelector } from '../model-selector' import styles from './media-page.module.scss' +import { useMediaBottomScroll } from './use-media-bottom-scroll' const MEDIA_MODEL_SLUG_KEY = 'media-selected-image-model' const MEDIA_VIEW_MODE_KEY = 'media-view-mode' @@ -41,6 +44,7 @@ export const MediaPage: NextPageWithLayout = () => { const deviceType = getDeviceType() const { desktop } = useDeviceType(deviceType, deviceOs) const { data: session } = useSession() + const { push } = useRouter() const { showMessage } = useShowDataStore() const payment_plan = useAppSelector((state) => state.user.payment_plan) const scrollContainerRef = useRef(null) @@ -51,8 +55,7 @@ export const MediaPage: NextPageWithLayout = () => { const [prompt, setPrompt] = useState('') const [paramsOpen, setParamsOpen] = useState(false) const [viewMode, setViewMode] = useState(readSavedViewMode) - - const profilePicture = useAppSelector((state) => state.user.profile_picture_link) + const [newViewEnabled, setNewViewEnabled] = useState(true) const modelTitles = useMemo(() => { const map: Record = {} @@ -71,7 +74,8 @@ export const MediaPage: NextPageWithLayout = () => { 'image', deviceType, setItems, - scrollContainerRef + scrollContainerRef, + { pinToBottom: true } ) const { onCreateImage, onLoadImage, image, setImage, pinImageFromUrl } = useImagesUniqInput( version, @@ -120,16 +124,28 @@ export const MediaPage: NextPageWithLayout = () => { setModels(all) const savedSlug = typeof window !== 'undefined' ? localStorage.getItem(MEDIA_MODEL_SLUG_KEY) : null + const savedModel = savedSlug ? all.find((model) => model.slug === savedSlug) : undefined const initial = - (savedSlug && all.find((model) => model.slug === savedSlug)?.slug) || + (savedModel && !savedModel.blocked ? savedModel.slug : null) || available[0]?.slug || - all[0]?.slug || '' setSelectedSlug(initial) }) }, [session?.access]) + useEffect(() => { + if (!models.length || !selectedSlug) return + + const current = models.find((model) => model.slug === selectedSlug) + if (current && !current.blocked) return + + const fallback = models.find((model) => !model.blocked) + if (fallback) { + setSelectedSlug(fallback.slug) + } + }, [models, selectedSlug]) + useEffect(() => { if (!session?.access || !selectedSlug) return @@ -141,16 +157,46 @@ export const MediaPage: NextPageWithLayout = () => { localStorage.setItem(MEDIA_VIEW_MODE_KEY, viewMode) }, [viewMode]) + useMediaBottomScroll({ + scrollContainerRef, + items, + loading, + paginationLoading, + hasMore, + loadMore, + }) + const handleSelectModel = (slug: string) => { if (slug === selectedSlug) return setSelectedSlug(slug) - setPrompt('') setImage(null) } + const handleNewViewChange = (checked: boolean) => { + if (checked) { + setNewViewEnabled(true) + return + } + + if (!selectedSlug) { + return + } + + setNewViewEnabled(false) + void push(`/images/${selectedSlug}`) + } + return (
+
+ +
+
{ onPinImageFromUrl={pinImageFromUrl} scrollRootRef={scrollContainerRef} modelTitles={modelTitles} - avatarUrl={profilePicture} /> )}
@@ -0,0 +1,191 @@ +import { RefObject, useEffect, useLayoutEffect, useRef } from 'react' + +type UseMediaBottomScrollParams = { + scrollContainerRef: RefObject + items: { uid: string }[] | null + loading: boolean + paginationLoading: boolean + hasMore: boolean + loadMore: () => Promise +} + +/** + * Чат-стиль: контент растёт вверх, viewport прижат к низу. + * Старые страницы подгружаются у верхнего края со scroll-anchor. + */ +export function useMediaBottomScroll({ + scrollContainerRef, + items, + loading, + paginationLoading, + hasMore, + loadMore, +}: UseMediaBottomScrollParams) { + const prevSnapshotRef = useRef<{ length: number; firstUid?: string; lastUid?: string } | null>(null) + const scrollAnchorRef = useRef<{ + uid: string + topOffset: number + scrollTop: number + scrollHeight: number + } | null>(null) + const prependRestoreObserverRef = useRef(null) + const prependRestoreTimeoutRef = useRef | null>(null) + const isRestoringScrollRef = useRef(false) + + const getTopInViewport = (block: HTMLDivElement, el: HTMLElement) => + el.getBoundingClientRect().top - block.getBoundingClientRect().top + + const captureScrollAnchor = (block: HTMLDivElement) => { + const anchorUid = items?.[0]?.uid + if (!anchorUid) { + return + } + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchorUid)}"]`) as HTMLElement | null + + scrollAnchorRef.current = { + uid: anchorUid, + topOffset: el ? getTopInViewport(block, el) : 0, + scrollTop: block.scrollTop, + scrollHeight: block.scrollHeight, + } + } + + const restorePrependScroll = (block: HTMLDivElement) => { + const anchor = scrollAnchorRef.current + if (!anchor) { + return + } + + const apply = () => { + block.scrollTop = anchor.scrollTop + (block.scrollHeight - anchor.scrollHeight) + + const el = block.querySelector(`[data-message-uid="${CSS.escape(anchor.uid)}"]`) as HTMLElement | null + if (el) { + const currentTop = getTopInViewport(block, el) + block.scrollTop = block.scrollTop + currentTop - anchor.topOffset + } + } + + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + + isRestoringScrollRef.current = true + apply() + + const observer = new ResizeObserver(() => { + apply() + }) + prependRestoreObserverRef.current = observer + observer.observe(block) + + requestAnimationFrame(() => { + apply() + requestAnimationFrame(() => { + apply() + isRestoringScrollRef.current = false + }) + }) + + prependRestoreTimeoutRef.current = setTimeout(() => { + observer.disconnect() + prependRestoreObserverRef.current = null + scrollAnchorRef.current = null + isRestoringScrollRef.current = false + }, 3000) + } + + useEffect(() => { + return () => { + prependRestoreObserverRef.current?.disconnect() + if (prependRestoreTimeoutRef.current) { + clearTimeout(prependRestoreTimeoutRef.current) + } + } + }, []) + + useEffect(() => { + if (!items?.length) { + prevSnapshotRef.current = null + scrollAnchorRef.current = null + } + }, [items]) + + useLayoutEffect(() => { + if (loading || !items?.length) { + return + } + + const block = scrollContainerRef.current + if (!block) { + return + } + + const snapshot = { + length: items.length, + firstUid: items[0]?.uid, + lastUid: items[items.length - 1]?.uid, + } + const prev = prevSnapshotRef.current + + if (!prev) { + block.scrollTop = block.scrollHeight + prevSnapshotRef.current = snapshot + return + } + + const prepended = snapshot.length > prev.length && snapshot.lastUid === prev.lastUid + const appended = snapshot.lastUid !== prev.lastUid && snapshot.length >= prev.length && !prepended + + if (prepended && scrollAnchorRef.current) { + restorePrependScroll(block) + } else if (appended) { + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } + + prevSnapshotRef.current = snapshot + }, [items, loading, scrollContainerRef]) + + useEffect(() => { + const block = scrollContainerRef.current + if (!block || !hasMore) { + return + } + + const handleScroll = () => { + if (!items?.length || paginationLoading || isRestoringScrollRef.current) { + return + } + + if (block.scrollTop > 48) { + return + } + + captureScrollAnchor(block) + void loadMore() + } + + block.addEventListener('scroll', handleScroll, { passive: true }) + return () => block.removeEventListener('scroll', handleScroll) + }, [scrollContainerRef, items, hasMore, loadMore, paginationLoading]) + + // Догружаем следующую страницу, если после prepend всё ещё у верхнего края + useEffect(() => { + if (paginationLoading || loading || !hasMore || !items?.length) { + return + } + + const block = scrollContainerRef.current + if (!block || isRestoringScrollRef.current || block.scrollTop > 48) { + return + } + + captureScrollAnchor(block) + void loadMore() + }, [paginationLoading, loading, hasMore, items, loadMore, scrollContainerRef]) +} @@ -121,7 +121,7 @@ .paginationLoader { display: flex; justify-content: center; - padding-top: 20px; + padding-bottom: 20px; } .sentinel { @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Box, CircularProgress, Skeleton, Typography } from '@mui/material' import Image from 'next/image' @@ -23,7 +23,7 @@ export const MediaPageContentPaginated = ({ canAttachFile = false, onPinImageFromUrl, onPromptClick, - scrollRootRef, + scrollRootRef: _scrollRootRef, }: MediaPageContentPaginatedProps) => { const paginationSentinelRef = useRef(null) const gridRef = useRef(null) @@ -42,35 +42,6 @@ export const MediaPageContentPaginated = ({ const { gap, rows, setAspect } = useJustifiedMediaGrid(uids, gridRef) - 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) @@ -89,7 +60,7 @@ export const MediaPageContentPaginated = ({ } }} setModal={setModal} - reverse + reverse={false} current={chosenImage} />, document.getElementById('modal-container')! @@ -104,6 +75,17 @@ export const MediaPageContentPaginated = ({ Пока нет генераций ) : ( <> + {hasMore && ( + <> +
+ {paginationLoading && ( + + + + )} + + )} +
{rows.map((row, rowIndex) => (
@@ -141,6 +124,7 @@ export const MediaPageContentPaginated = ({ return (
@@ -214,17 +198,6 @@ export const MediaPageContentPaginated = ({
))}
- - {hasMore && ( - <> - {paginationLoading && ( - - - - )} -
- - )} )} @@ -3,7 +3,7 @@ align-items: center; gap: 2px; padding: 6px; - border-radius: 12px; + border-radius: 14px; background: #151518; backdrop-filter: blur(8px); } @@ -15,7 +15,7 @@ width: 36px; height: 36px; border: none; - border-radius: 12px; + border-radius: 10px; background: transparent; color: #fff; cursor: pointer; @@ -1,49 +1,83 @@ .root { pointer-events: auto; + min-width: 0; + max-width: 100%; } .trigger { display: inline-flex; align-items: center; gap: 10px; + max-width: 100%; + min-width: 0; padding: 8px 10px 8px 16px; border: none; - border-radius: 20px; + border-radius: 14px; 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; + + @media (max-width: 766px) { + gap: 6px; + padding: 6px 8px 6px 12px; + } } .label { + flex-shrink: 0; color: #fff; font-size: 20px; font-weight: 500; line-height: 1.2; + + @media (max-width: 766px) { + font-size: 16px; + } + + @media (max-width: 400px) { + display: none; + } } .nameChip { display: inline-flex; align-items: center; gap: 4px; + min-width: 0; + max-width: 100%; padding: 4px 10px 4px 12px; - border: 1px solid #7f7df3; + border: 1px solid #8280ff26; border-radius: 14px; - background: #7f7df3; + background: #8280ff26; + + @media (max-width: 766px) { + flex: 1 1 auto; + overflow: hidden; + } } .name { - color: #fff; + color: #8280ff; font-size: 20px; font-weight: 600; line-height: 1.2; + + @media (max-width: 766px) { + min-width: 0; + overflow: hidden; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; + } } .chevron { + flex-shrink: 0; font-size: 22px !important; - color: #fff; + color: #8280ff; transition: transform 0.15s ease; } @@ -53,139 +87,271 @@ .menuPaper { margin-top: 10px !important; - width: min(440px, calc(100vw - 32px)); + width: min(440px, calc(100vw - 24px)) !important; + max-width: calc(100vw - 24px) !important; 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; + overflow: hidden !important; } .menuList { max-height: min(420px, 70vh); + overflow-x: hidden; overflow-y: auto; padding: 8px !important; display: flex; flex-direction: column; + align-items: stretch; gap: 4px; + box-sizing: border-box; } .menuItem { display: flex !important; + flex-direction: row !important; align-items: flex-start !important; justify-content: space-between !important; + flex: 0 0 auto !important; gap: 12px; - padding: 14px 16px !important; + width: 100% !important; + height: auto !important; + min-height: unset !important; + padding: 12px 14px !important; border-radius: 16px !important; white-space: normal !important; background: transparent !important; opacity: 1 !important; + box-sizing: border-box !important; + overflow: visible; &:hover { - background: rgba(127, 125, 243, 0.12) !important; + background: #8280ff1f !important; } &.Mui-disabled { opacity: 1 !important; } + + @media (max-width: 766px) { + padding: 10px 12px !important; + gap: 8px; + } } .menuItemSelected { - background: rgba(127, 125, 243, 0.16) !important; + background: #8280ff26 !important; &:hover { - background: rgba(127, 125, 243, 0.2) !important; + background: #8280ff33 !important; } } .menuItemMuted { .itemTitle { - color: rgba(255, 255, 255, 0.72); + color: rgba(255, 255, 255, 0.45); } .itemDescription { - color: rgba(164, 170, 181, 0.75); + color: rgba(164, 170, 181, 0.55); } } .itemBody { display: flex; flex-direction: column; - gap: 8px; + align-items: stretch; + gap: 6px; min-width: 0; - flex: 1; + flex: 1 1 auto; + overflow: visible; } .itemHeader { display: flex; flex-wrap: wrap; align-items: center; - gap: 8px; + gap: 6px 8px; + min-width: 0; + width: 100%; } .itemTitle { + display: inline-block; font-size: 18px; font-weight: 600; line-height: 1.25; color: #fff; + + @media (max-width: 766px) { + font-size: 16px; + } } .itemTitleSelected { - color: #7f7df3; + color: #8280ff; } .itemDescription { + margin: 0 !important; + padding: 0 !important; font-size: 13px !important; line-height: 1.4 !important; color: #a4aab5 !important; + display: block !important; + position: static !important; + width: 100%; + overflow-wrap: anywhere; + word-break: break-word; } .tags { display: flex; flex-wrap: wrap; align-items: center; - gap: 6px; + gap: 4px; + min-width: 0; + flex: 0 1 auto; } .tag { display: inline-flex; align-items: center; + flex: 0 0 auto; gap: 4px; - padding: 2px 8px; - border: 1px solid #7f7df3; - border-radius: 999px; - font-size: 12px; + height: 24px; + max-height: 24px; + box-sizing: border-box; + padding: 0 6px; + border: 1px solid rgba(255, 255, 255, 0.85); + border-radius: 4px; + font-size: 11px; font-weight: 500; - line-height: 1.4; + line-height: 1; + color: #fff; background: transparent; + max-width: 100%; + overflow: hidden; +} + +.tagSelected { + border-color: #8280ff; + color: #8280ff; + background: #8280ff26; +} + +.tagMuted { + border-color: rgba(255, 255, 255, 0.35); + color: rgba(255, 255, 255, 0.45); } .tagIcon { + display: inline-flex; flex-shrink: 0; + align-items: center; + justify-content: center; + width: 10px; + height: 10px; + min-width: 10px; + min-height: 10px; + max-width: 10px; + max-height: 10px; + overflow: hidden; + color: inherit; + line-height: 0; + + :global(div) { + display: flex !important; + align-items: center; + justify-content: center; + width: 10px !important; + height: 10px !important; + min-width: 10px !important; + min-height: 10px !important; + max-width: 10px !important; + max-height: 10px !important; + overflow: hidden; + line-height: 0; + color: inherit; + } + + :global(svg) { + display: block; + width: 10px !important; + height: 10px !important; + max-width: 10px !important; + max-height: 10px !important; + color: inherit; + } +} + +.tagIconFilled { color: inherit; + + :global(svg), + :global(svg *) { + fill: currentColor !important; + stroke: currentColor !important; + color: inherit !important; + } +} + +.tagIconOutline { + color: inherit; + + :global(svg) { + fill: none !important; + stroke: currentColor !important; + color: inherit !important; + } + + :global(svg path), + :global(svg circle), + :global(svg rect), + :global(svg polygon), + :global(svg ellipse), + :global(svg line), + :global(svg polyline) { + fill: none !important; + stroke: currentColor !important; + stroke-width: 1.25px; + stroke-linecap: round; + stroke-linejoin: round; + color: inherit !important; + } } .tagText { white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .itemStatus { display: flex; - align-items: center; + align-items: flex-start; justify-content: center; - flex-shrink: 0; + flex: 0 0 auto; min-width: 24px; padding-top: 2px; } .statusSelected { font-size: 22px !important; - color: #7f7df3; + color: #8280ff; } .statusBlocked { font-size: 22px !important; color: #e84d4d; } + +.statusBlockedWrap { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 0; +} @@ -2,12 +2,14 @@ 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 { Menu, MenuItem } from '@mui/material' import { useRouter } from 'next/router' import LockSvg from '#/assets/svg/lock.svg?react' import { IShortModel } from '#/entities/model-entity' +import { TooltipCustom } from '#/shared' import { c } from '#/shared/lib/helpers' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { SvgIcon } from '#/shared/ui/svg' import styles from './model-selector.module.scss' @@ -30,6 +32,7 @@ export const ModelSelector = ({ const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) const { push } = useRouter() + const { showMessage } = useShowDataStore() const selected = models.find((model) => model.slug === selectedSlug) const title = selected?.title || 'Модель' @@ -37,7 +40,10 @@ export const ModelSelector = ({ const isLocked = (slug: string) => Boolean(accessedModels && !accessedModels.includes(slug)) const handleSelect = (model: IShortModel) => { - if (model.blocked) return + if (model.blocked) { + showMessage('Модель заблокирована, пожалуйста выберите другую модель') + return + } if (isLocked(model.slug)) { setAnchorEl(null) @@ -77,7 +83,7 @@ export const ModelSelector = ({ }, }} MenuListProps={{ - className: styles.menuList, + className: c(styles.menuList, 'smallScroll'), }} > {models.map((model) => { @@ -88,13 +94,21 @@ export const ModelSelector = ({ handleSelect(model)} className={c( styles.menuItem, isSelected && styles.menuItemSelected, - (model.blocked || locked) && styles.menuItemMuted + model.blocked && styles.menuItemMuted )} + sx={{ + flex: '0 0 auto', + height: 'auto', + minHeight: 'unset', + alignItems: 'flex-start', + whiteSpace: 'normal', + py: 1.5, + px: 1.75, + }} >
@@ -107,14 +121,23 @@ export const ModelSelector = ({ {model.tags.map((tag, index) => ( {tag.icon && ( - + + + )} {tag.title} @@ -124,7 +147,7 @@ export const ModelSelector = ({
{model.description && ( - {model.description} +

{model.description}

)}
@@ -133,7 +156,13 @@ export const ModelSelector = ({ )} {locked && !model.blocked && } - {model.blocked && } + {model.blocked && ( + + + + + + )}
) @@ -114,12 +114,12 @@ export const Layout: React.FC = ({ sx={{ width: '100%', height: desktop ? (height ? height : '100%') : '100dvh', - padding: desktop ? '0px' : '10px', + padding: desktop || darkSurface ? '0px' : '10px', }} > {desktop ? (