@@ -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 && (
- 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 && (
+
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)}>