@@ -81,6 +81,10 @@ width: 100%; justify-content: space-between; align-items: center; + + .endActions { + gap: 4px; + } } .endActions { @@ -110,6 +114,11 @@ } } +.paramsBtnPlaceholder { + visibility: hidden; + pointer-events: none; +} + .divider { height: 20px; width: 1px; @@ -249,12 +249,8 @@ export const ModelInput: FC = ({ ? 'Опишите то, что хотите сгенерировать' : 'Ваше сообщение') - const paramsButton = showParamsButton ? ( - + ) : isStacked ? ( + + {paramsButtonContent} + ) : null const controls = ( @@ -0,0 +1,44 @@ +function declineRu(n: number, one: string, few: string, many: string) { + const abs = Math.abs(n) % 100 + const last = abs % 10 + if (abs > 10 && abs < 20) return many + if (last === 1) return one + if (last >= 2 && last <= 4) return few + return many +} + +export function formatMediaRelativeTime(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) { + return '' + } + + const diffMs = Date.now() - date.getTime() + if (diffMs < 0) { + return 'только что' + } + + const minutes = Math.floor(diffMs / 60_000) + if (minutes < 1) return 'только что' + if (minutes < 60) { + return `${minutes} ${declineRu(minutes, 'минуту', 'минуты', 'минут')} назад` + } + + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours} ${declineRu(hours, 'час', 'часа', 'часов')} назад` + } + + const days = Math.floor(hours / 24) + if (days < 30) { + return `${days} ${declineRu(days, 'день', 'дня', 'дней')} назад` + } + + const months = Math.floor(days / 30) + if (months < 12) { + return `${months} ${declineRu(months, 'месяц', 'месяца', 'месяцев')} назад` + } + + const years = Math.floor(days / 365) + return `${years} ${declineRu(years, 'год', 'года', 'лет')} назад` +} @@ -0,0 +1 @@ +export { MediaListView } from './media-list-view' @@ -0,0 +1,219 @@ +.list { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + width: 100%; + padding: 12px 80px 0 0; + box-sizing: border-box; + + @media (max-width: 766px) { + padding: 12px 12px 0; + } +} + +.card { + display: grid; + grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); + grid-template-rows: auto 1fr; + grid-template-areas: + 'info media' + 'actions media'; + align-items: stretch; + column-gap: 20px; + row-gap: 12px; + width: 100%; + max-width: 1080px; + padding: 4px; + border-radius: 20px; + background: #1a1a1e; + box-sizing: border-box; + + @media (max-width: 766px) { + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-template-areas: + 'info' + 'media' + 'actions'; + row-gap: 12px; + column-gap: 0; + padding: 16px; + } +} + +.infoTop { + grid-area: info; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 22px; + + @media (max-width: 766px) { + padding: 0; + } +} + +.metaRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.time { + 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; + align-items: center; + gap: 6px; + width: fit-content; + padding: 4px 10px; + border-radius: 999px; + background: #7f7df3; + color: #fff; + font-size: 13px; + font-weight: 600; +} + +.modelIcon { + font-size: 16px !important; +} + +.description { + margin: 0; + color: #a4aab5; + font-size: 15px; + line-height: 1.45; + word-break: break-word; +} + +.actions { + grid-area: actions; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + align-content: end; + width: 100%; + min-width: 0; + padding: 0 22px 22px; + + @media (max-width: 766px) { + padding: 0; + margin-top: 4px; + } +} + +.actionBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + height: 34px; + padding: 4px 12px; + border: none; + border-radius: 6px; + background: #A4AAB51A; + color: #A4AAB5; + font: inherit; + font-size: 12px; + font-weight: 500; + cursor: pointer; + box-sizing: border-box; + white-space: nowrap; + text-align: center; + + &:disabled { + opacity: 0.5; + cursor: default; + } + + &:not(:disabled):hover { + background: #34343c; + } +} + +.actionIcon { + font-size: 18px !important; +} + +.actionIconAttach { + transform: rotate(-45deg); +} + +.media { + grid-area: media; + position: relative; + z-index: 0; + width: 100%; + min-width: 0; + max-width: 100%; + border-radius: 16px; + overflow: hidden; + background: #2d2d2f; + cursor: pointer; + line-height: 0; +} + +.image { + display: block; + position: static !important; + width: 100% !important; + height: auto !important; + max-width: 100%; + border-radius: 16px; +} + +.mediaEmpty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + color: #a4aab5; + line-height: normal; +} + +.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,223 @@ +import { useEffect, 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' +import LanguageIcon from '@mui/icons-material/Language' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' +import { Box, CircularProgress, Typography } from '@mui/material' +import Image from 'next/image' + +import { Message } from '#/entities/message' +import { ImageModal } from '#/features/image-modal' +import { ClientOnly, Loader } from '#/shared' +import { c } from '#/shared/lib/helpers' +import { useMessages } from '#/widgets/messages' +import { useImageIcons } from '#/widgets/messages/model/use-image-icons' + +import { formatMediaRelativeTime } from './format-media-relative-time' +import styles from './media-list-view.module.scss' + +interface MediaListViewProps { + items: Message[] | null + loading: boolean + paginationLoading: boolean + hasMore: boolean + loadMore: () => Promise + canAttachFile?: boolean + onPinImageFromUrl?: (url: string) => void + scrollRootRef?: RefObject + modelTitles?: Record + avatarUrl?: string | null +} + +export const MediaListView = ({ + items, + loading, + paginationLoading, + hasMore, + loadMore, + canAttachFile = false, + onPinImageFromUrl, + scrollRootRef, + modelTitles = {}, + avatarUrl, +}: MediaListViewProps) => { + const paginationSentinelRef = useRef(null) + const [modal, setModal] = useState(false) + const [imageSizes, setImageSizes] = useState>({}) + 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 ( + + + + ) + } + + if (items?.length === 0) { + return Пока нет генераций + } + + return ( + <> + + {createPortal( + { + if (hasMore) { + void loadMore() + } + }} + setModal={setModal} + reverse + current={chosenImage} + />, + document.getElementById('modal-container')! + )} + + +
+ {items?.map((message) => { + const file = message.file?.toString() ?? '' + const modelTitle = modelTitles[message.model] || message.model + const relative = formatMediaRelativeTime(message.created_at) + + return ( +
+
+
+ {relative} + {avatarUrl ? ( + + ) : ( +
+ )} +
+ + {modelTitle && ( + + + {modelTitle} + + )} + +

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

+
+ +
+ {file ? ( + {message.content { + setImageSizes((prev) => { + if (prev[message.uid]?.width === img.naturalWidth) { + return prev + } + return { + ...prev, + [message.uid]: { + width: img.naturalWidth, + height: img.naturalHeight, + }, + } + }) + }} + onClick={() => { + setChosenImage(file) + setModal(true) + }} + /> + ) : ( +
Нет файла
+ )} +
+ +
+ + + {canAttachFile && ( + + )} +
+
+ ) + })} +
+ + {hasMore && ( + <> + {paginationLoading && ( + + + + )} +
+ + )} + + ) +} @@ -8,25 +8,64 @@ overflow: hidden; } -.selectorOverlay { +.topControls { position: absolute; top: 16px; + left: 0; + right: 0; + z-index: 12; + 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%); - z-index: 12; pointer-events: none; > * { pointer-events: auto; } + + @media (max-width: 766px) { + position: static; + transform: none; + pointer-events: auto; + } +} + +.viewToggleOverlay { + position: absolute; + top: 0; + right: 96px; + pointer-events: auto; + + @media (max-width: 766px) { + position: static; + right: auto; + } } .gridScroll { flex: 1; min-height: 0; + padding-top: 80px; overflow-x: hidden; overflow-y: auto; padding-bottom: 160px; + + @media (max-width: 766px) { + padding-top: 92px; + } } .inputBar { @@ -17,12 +17,24 @@ import { useDeviceType } from '#/shared/lib/hooks' import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' import { MediaPageContentPaginated } from '../media-page-content-paginated' +import { MediaListView } from '../media-list-view' 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' const MEDIA_MODEL_SLUG_KEY = 'media-selected-image-model' +const MEDIA_VIEW_MODE_KEY = 'media-view-mode' + +const readSavedViewMode = (): MediaViewMode => { + if (typeof window === 'undefined') { + return 'grid' + } + + const saved = localStorage.getItem(MEDIA_VIEW_MODE_KEY) + return saved === 'list' || saved === 'grid' ? saved : 'grid' +} export const MediaPage: NextPageWithLayout = () => { const deviceOs = getOs() @@ -38,6 +50,17 @@ export const MediaPage: NextPageWithLayout = () => { const [selectedSlug, setSelectedSlug] = useState('') const [prompt, setPrompt] = useState('') const [paramsOpen, setParamsOpen] = useState(false) + const [viewMode, setViewMode] = useState(readSavedViewMode) + + const profilePicture = useAppSelector((state) => state.user.profile_picture_link) + + const modelTitles = useMemo(() => { + const map: Record = {} + for (const model of models) { + map[model.slug] = model.title + } + return map + }, [models]) const { botParams, version, modelType, fetchBotParams, resetParams } = useImageBot(selectedSlug) const { includeParams } = useImagesBotFilters() @@ -114,6 +137,10 @@ export const MediaPage: NextPageWithLayout = () => { void fetchBotParams() }, [session?.access, selectedSlug]) + useEffect(() => { + localStorage.setItem(MEDIA_VIEW_MODE_KEY, viewMode) + }, [viewMode]) + const handleSelectModel = (slug: string) => { if (slug === selectedSlug) return setSelectedSlug(slug) @@ -123,26 +150,48 @@ export const MediaPage: NextPageWithLayout = () => { return (
-
- +
+
+ +
+ +
+ +
- + {viewMode === 'grid' ? ( + + ) : ( + + )}
@@ -3,40 +3,77 @@ min-height: 320px; padding: 0 80px 0 0; border-radius: 0; + + @media (max-width: 766px) { + padding: 0 12px; + } } .grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 4px; + display: flex; + flex-direction: column; + align-items: stretch; 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)); - } +.row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + width: 100%; + box-sizing: border-box; } .tile { position: relative; display: block; - width: 100%; + flex: 0 0 auto; + box-sizing: border-box; border-radius: 4px; overflow: hidden; cursor: pointer; background: #2d2d2f; } +.title { + position: absolute; + left: 12px; + bottom: 12px; + z-index: 2; + max-width: calc(100% - 56px); + margin: 0; + background-color: rgba(#151518, 0.5); + font-size: 13px; + padding: 2px 8px; + border-radius: 10px; + font-weight: 500; + line-height: 1.3; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.titleTop { + top: 12px; + bottom: auto; +} + +.titleClickable { + cursor: pointer; + + &:hover { + background-color: rgba(#151518, 0.7); + } +} + .tileZip { display: flex; flex-direction: column; align-items: center; justify-content: center; - min-height: 180px; border: 1px solid #8280ff; cursor: default; padding: 12px; @@ -46,10 +83,10 @@ .image { display: block; width: 100%; - height: auto; + height: 100%; + object-fit: cover; border-radius: 4px; user-select: none; - vertical-align: top; } .imageVisible { @@ -58,29 +95,15 @@ .imageHidden { opacity: 0; - position: absolute; } .skeleton { display: block; width: 100%; - min-height: 200px; + height: 100%; 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; @@ -1,9 +1,10 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, 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 { Message } from '#/entities/message' import { ImageModal } from '#/features/image-modal' import { ClientOnly, Loader } from '#/shared' import { c } from '#/shared/lib/helpers' @@ -11,6 +12,7 @@ import { ImageIcons, useMessages } from '#/widgets/messages' import styles from './media-page-content-paginated.module.scss' import { MediaPageContentPaginatedProps } from './types' +import { useJustifiedMediaGrid } from './use-justified-media-grid' export const MediaPageContentPaginated = ({ items, @@ -20,13 +22,26 @@ export const MediaPageContentPaginated = ({ loadMore, canAttachFile = false, onPinImageFromUrl, + onPromptClick, scrollRootRef, }: MediaPageContentPaginatedProps) => { const paginationSentinelRef = useRef(null) + const gridRef = useRef(null) const [modal, setModal] = useState(false) const [loadedMap, setLoadedMap] = useState>({}) const { chosenImage, setChosenImage, computedLibraryImages } = useMessages(items ?? []) + const uids = useMemo(() => (items ?? []).map((message) => message.uid), [items]) + const itemsByUid = useMemo(() => { + const map: Record = {} + for (const message of items ?? []) { + map[message.uid] = message + } + return map + }, [items]) + + const { gap, rows, setAspect } = useJustifiedMediaGrid(uids, gridRef) + useEffect(() => { if (!hasMore || !items?.length || loading) { return @@ -89,72 +104,115 @@ export const MediaPageContentPaginated = ({ Пока нет генераций ) : ( <> -
- {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 - } - /> -
- ) - })} +
+ {rows.map((row, rowIndex) => ( +
+ {row.items.map((layout) => { + const message = itemsByUid[layout.uid] + if (!message) { + return null + } + + const file = message.file?.toString() ?? '' + const isZip = file.includes('.zip') + const isLoaded = Boolean(loadedMap[message.uid]) + const tileWidth = layout.width + const tileHeight = layout.height + + if (isZip) { + return ( +
+ Эта генерация является архивом + + Скачать + +
+ ) + } + + return ( +
+ {file && ( + {message.content openModal(file)} + onLoadingComplete={(img) => { + setAspect(message.uid, img.naturalWidth, img.naturalHeight) + setLoadedMap((prev) => ({ ...prev, [message.uid]: true })) + }} + /> + )} + + {!isLoaded && ( + + )} + + {isLoaded && ( +

{ + e.stopPropagation() + if (message.content.length > 0) { + onPromptClick?.(message.content) + } + }} + > + {message.content.length > 0 + ? `${message.content.replaceAll('"', '').slice(0, 30)}${ + message.content.length > 30 ? '...' : '' + }` + : 'описание отсутствует'} +

+ )} + + onPinImageFromUrl(file) + : undefined + } + /> +
+ ) + })} +
+ ))}
{hasMore && ( @@ -9,5 +9,6 @@ export interface MediaPageContentPaginatedProps { loadMore: () => Promise canAttachFile?: boolean onPinImageFromUrl?: (url: string) => void + onPromptClick?: (content: string) => void scrollRootRef?: RefObject } @@ -0,0 +1,160 @@ +import { useCallback, useLayoutEffect, useMemo, useState, type RefObject } from 'react' + +const GAP = 4 +const TARGET_ROW_HEIGHT = 260 +const DEFAULT_ASPECT = 1 + +export type JustifiedLayoutItem = { + uid: string + width: number + height: number +} + +export type JustifiedLayoutRow = { + height: number + items: JustifiedLayoutItem[] +} + +type AspectMap = Record + +/** Round widths so their sum exactly equals `totalWidth` (no leftover flex holes). */ +function distributeWidths(rawWidths: number[], totalWidth: number): number[] { + if (rawWidths.length === 0) { + return [] + } + + const floors = rawWidths.map((width) => Math.floor(width)) + let used = floors.reduce((sum, width) => sum + width, 0) + let remaining = Math.max(0, Math.round(totalWidth) - used) + + const order = rawWidths + .map((width, index) => ({ index, frac: width - Math.floor(width) })) + .sort((a, b) => b.frac - a.frac) + + const result = [...floors] + let cursor = 0 + while (remaining > 0 && order.length > 0) { + result[order[cursor % order.length].index] += 1 + remaining -= 1 + cursor += 1 + } + + return result +} + +function buildJustifiedRows(uids: string[], aspects: AspectMap, containerWidth: number): JustifiedLayoutRow[] { + if (!uids.length || containerWidth <= 0) { + return [] + } + + const rows: JustifiedLayoutRow[] = [] + let row: string[] = [] + let rowAspectSum = 0 + + const flushRow = () => { + if (row.length === 0) { + return + } + + const gaps = GAP * Math.max(row.length - 1, 0) + const availableWidth = Math.max(Math.floor(containerWidth) - gaps, row.length) + const height = availableWidth / rowAspectSum + const rawWidths = row.map((uid) => height * (aspects[uid] ?? DEFAULT_ASPECT)) + const widths = distributeWidths(rawWidths, availableWidth) + const rowHeight = Math.max(1, Math.round(height)) + + rows.push({ + height: rowHeight, + items: row.map((uid, index) => ({ + uid, + width: widths[index], + height: rowHeight, + })), + }) + + row = [] + rowAspectSum = 0 + } + + for (const uid of uids) { + const aspect = aspects[uid] ?? DEFAULT_ASPECT + row.push(uid) + rowAspectSum += aspect + + const gaps = GAP * Math.max(row.length - 1, 0) + const rowWidthAtTarget = rowAspectSum * TARGET_ROW_HEIGHT + gaps + + // Row is full enough — scale all items in it to exactly fill container width. + if (rowWidthAtTarget >= containerWidth) { + flushRow() + } + } + + // Last row also stretches to full width (no trailing hole). + if (row.length > 0) { + flushRow() + } + + return rows +} + +export function useJustifiedMediaGrid(uids: string[], containerRef: RefObject) { + const [containerWidth, setContainerWidth] = useState(0) + const [aspects, setAspects] = useState({}) + + useLayoutEffect(() => { + const node = containerRef.current + if (!node) { + return + } + + const update = () => { + const nextWidth = Math.floor(node.clientWidth) + setContainerWidth((prev) => (prev === nextWidth ? prev : nextWidth)) + } + + update() + + const observer = new ResizeObserver(update) + observer.observe(node) + + return () => observer.disconnect() + // Re-bind when items appear: grid is unmounted during the initial loading state. + }, [containerRef, uids.length]) + + const setAspect = useCallback((uid: string, width: number, height: number) => { + if (!width || !height) { + return + } + + const aspect = width / height + setAspects((prev) => { + if (prev[uid] && Math.abs(prev[uid] - aspect) < 0.001) { + return prev + } + return { ...prev, [uid]: aspect } + }) + }, []) + + const rows = useMemo( + () => buildJustifiedRows(uids, aspects, containerWidth), + [uids, aspects, containerWidth] + ) + + const layoutByUid = useMemo(() => { + const map: Record = {} + for (const row of rows) { + for (const item of row.items) { + map[item.uid] = item + } + } + return map + }, [rows]) + + return { + gap: GAP, + rows, + layoutByUid, + setAspect, + } +} @@ -0,0 +1,2 @@ +export { MediaViewToggle } from './media-view-toggle' +export type { MediaViewMode } from './media-view-toggle' @@ -0,0 +1,31 @@ +.root { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 6px; + border-radius: 12px; + background: #151518; + backdrop-filter: blur(8px); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + border-radius: 12px; + background: transparent; + color: #fff; + cursor: pointer; + padding: 0; +} + +.btnActive { + background: rgba(255, 255, 255, 0.12); +} + +.icon { + font-size: 16px !important; +} @@ -0,0 +1,39 @@ +import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded' +import ViewAgendaRoundedIcon from '@mui/icons-material/ViewAgendaRounded' + +import { c } from '#/shared/lib/helpers' + +import styles from './media-view-toggle.module.scss' + +export type MediaViewMode = 'grid' | 'list' + +interface MediaViewToggleProps { + value: MediaViewMode + onChange: (mode: MediaViewMode) => void + className?: string +} + +export const MediaViewToggle = ({ value, onChange, className }: MediaViewToggleProps) => { + return ( +
+ + +
+ ) +} @@ -1,3 +1,7 @@ +.header { + padding: 12px; +} + .requisitesLink { margin-top: 10px; margin-left: 20px; @@ -46,7 +46,13 @@ export const MainMenuMobile = memo(() => { return ( - + setAnchorElNavigate(event.currentTarget)}>