@@ -0,0 +1,3 @@ + + + @@ -0,0 +1,126 @@ +.pointer { + cursor: pointer; +} + +.container { + position: relative; + width: 46px; + margin: -12px 0; + height: 46px; + flex-shrink: 0; + background-color: #40404e; + border-radius: 4px; +} + +.previewImage { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 4px; +} + +.previewIcon { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + background-color: #8280FF; +} + +.previewClickable { + cursor: pointer; +} + +.tooltipIconPlaceholder { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background-color: #2a2a2e; + border-radius: 12px; + min-width: 128px; + min-height: 144px; +} + +.tooltipContent { + position: relative; + display: inline-block; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient(to top, #151518, transparent); + pointer-events: none; + border-radius: 0 0 10px 10px; + z-index: 0; + } +} + +.tooltipImage { + display: block; + max-width: 300px; + max-height: 300px; + object-fit: contain; + border-radius: 12px; +} + +.tooltipText { + position: absolute; + top: 0; + left: 0; + padding: 8px 8px; + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 2px; + font-size: 13px; + z-index: 1; + + .tooltipTextItem { + display: flex; + justify-content: space-between; + } +} + +.fileName { + min-width: 0; + overflow: hidden; +} + +.dimensionsText { + background-color: #151518; + border-radius: 12px; + padding: 4px 8px; +} + +.closeButton { + position: absolute; + top: -6px; + right: -4px; + width: 12px; + height: 12px; + padding: 8px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.8); + cursor: pointer; + + &:hover { + background-color: rgba(0, 0, 0, 1); + } +} + +.hiddenInput { + display: none; +} @@ -1,8 +1,9 @@ import React, { useRef, useState } from 'react' -import { Box, Typography } from '@mui/material' import Image from 'next/image' +import { Box, Tooltip, Typography } from '@mui/material' -import { TooltipCustom } from '#/shared' +import { getFileTypeIcon, isImageFile } from '#/shared/lib/helpers' +import styles from './load_image.module.scss' interface IProps { loading: boolean @@ -10,6 +11,8 @@ interface IProps { image: File | null | undefined imageLoad?: React.ChangeEventHandler types: string[] + fileNameMaxLength?: number + desktop?: boolean } const acceptTypes: any = { @@ -23,50 +26,145 @@ const acceptTypes: any = { text: '', } -export const LoadImage = ({ loading, unpinImage, image, imageLoad, types }: IProps) => { +const formatFileSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + + +const formatFileName = (name: string, maxLength: number = 30) => { + const lastDot = name.lastIndexOf('.') + const baseName = lastDot > 0 ? name.slice(0, lastDot) : name + const ext = lastDot > 0 ? name.slice(lastDot + 1) : '' + if (baseName.length <= maxLength) return name + return `${baseName.slice(0, maxLength)}...${ext}` +} + +export const LoadImage = ({ loading, unpinImage, image, imageLoad, types, fileNameMaxLength, desktop }: IProps) => { const ref = useRef(null) const [inputTypes, setInputTypes] = useState('') + const [previewUrl, setPreviewUrl] = useState(null) + const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null) React.useEffect(() => { setInputTypes(types.map((el) => acceptTypes[el]).toString()) }, [types]) + React.useEffect(() => { + if (!image) { + setPreviewUrl(null) + setDimensions(null) + return + } + if (!isImageFile(image)) { + setPreviewUrl(null) + setDimensions(null) + return + } + const url = URL.createObjectURL(image) + setPreviewUrl(url) + + const img = new window.Image() + img.onload = () => { + setDimensions({ width: img.naturalWidth, height: img.naturalHeight }) + } + img.src = url + + return () => URL.revokeObjectURL(url) + }, [image]) + if (!imageLoad || loading) { return null } if (image) { + const isImage = isImageFile(image) + const fileIcon = getFileTypeIcon(image) + + const tooltipContent = ( + + {isImage && previewUrl ? ( + Превью + ) : ( + + + + )} + + + {isImage && dimensions && ( + + {dimensions.width}x{dimensions.height}px + + )} + + {formatFileSize(image.size)} + + + + + {formatFileName(image.name, isImage ? 30 : 15)} + + + + + ) + return ( - - - - + + + {isImage && previewUrl ? ( + { e.stopPropagation(); unpinImage?.() } : undefined} + /> + ) : ( + { e.stopPropagation(); unpinImage?.() } : undefined} + > + + + )} + + { + e.stopPropagation() + unpinImage?.() + }} + > + Закрыть + + ) } return ( <> - + (ref.current! as any).click()} > { + try { + const response = await fetch(url, { credentials: 'include' }) + if (!response.ok) throw new Error('Не удалось загрузить изображение') + const blob = await response.blob() + const fileName = (url.split('/').pop() || 'image.png').slice(0, 90) + const mimeType = blob.type || 'image/png' + const file = new File([blob], fileName, { type: mimeType }) + setImage(file) + } catch { + showMessage('Не удалось прикрепить изображение') + } + }, [showMessage]) + function onCreateImage(input: string, required: (string | null)[]) { // про switch не слышали люди)) if (required.includes('text') && (input === '' || input === null)) { @@ -60,5 +74,6 @@ export function useImagesUniqInput( setImage, onLoadImage, onCreateImage, + pinImageFromUrl, } } @@ -186,6 +186,7 @@ export const ModelInput: FC
@@ -0,0 +1,44 @@ + +export const FILE_TYPE_ICONS: Record = { + doc: '/svg/filetypes/word-doc.svg', + docx: '/svg/filetypes/word-doc.svg', + txt: '/svg/filetypes/word-doc.svg', + pdf: '/svg/filetypes/word-doc.svg', + zip: '/svg/filetypes/word-doc.svg', + audio: '/svg/side-menu/audio.svg', +} + +/** Иконка по умолчанию для неизвестных типов */ +export const DEFAULT_FILE_ICON = '/svg/filetypes/file.svg' + +const IMAGE_MIME_PREFIXES = ['image/'] +const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] + +export function isImageFile(file: File): boolean { + const ext = getFileExtension(file.name) + if (IMAGE_EXTENSIONS.includes(ext)) return true + if (IMAGE_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))) return true + return false +} + +export function getFileTypeIcon(file: File): string { + const ext = getFileExtension(file.name) + const mime = file.type.toLowerCase() + + // По расширению + if (FILE_TYPE_ICONS[ext]) return FILE_TYPE_ICONS[ext] + + // По MIME + if (mime.includes('pdf')) return FILE_TYPE_ICONS.pdf + if (mime.includes('zip') || mime.includes('compressed')) return FILE_TYPE_ICONS.zip + if (mime.includes('msword') || mime.includes('wordprocessing')) return FILE_TYPE_ICONS.docx + if (mime.includes('audio')) return FILE_TYPE_ICONS.audio + if (mime.startsWith('text/')) return FILE_TYPE_ICONS.txt + + return DEFAULT_FILE_ICON +} + +function getFileExtension(name: string): string { + const lastDot = name.lastIndexOf('.') + return lastDot > 0 ? name.slice(lastDot + 1).toLowerCase() : '' +} @@ -6,3 +6,4 @@ export * from './get-random-image' export * from './string' export * from './date-helper' export * from './context' +export * from './file-type-icons' @@ -48,7 +48,7 @@ const ImageModelPage: NextPageWithLayout = () => { setMessages, mobileScrollContainer ) - const { onCreateImage, onLoadImage, image, setImage } = useImagesUniqInput(version, includeParams, createImage) + const { onCreateImage, onLoadImage, image, setImage, pinImageFromUrl } = useImagesUniqInput(version, includeParams, createImage) const { push } = useRouter() @@ -206,6 +206,7 @@ const ImageModelPage: NextPageWithLayout = () => { device={deviceType} images={messages} getMessagesPagination={fetchMessages} + onPinImageFromUrl={pinImageFromUrl} /> { device={deviceType} images={messages} getMessagesPagination={fetchMessages} + onPinImageFromUrl={pinImageFromUrl} /> @@ -9,13 +9,62 @@ export interface ImageIconsProps { url: string | null buttonText?: string content?: string + onPinImageClick?: () => void } -export const ImageIcons = ({ uid, url, content, buttonText }: ImageIconsProps) => { +export const ImageIcons = ({ uid, url, content, buttonText, onPinImageClick }: ImageIconsProps) => { const { toggleMenu, downloadFile, iconsMenu } = useImageIcons() return ( <> + {onPinImageClick && ( + + + { + e.stopPropagation() + onPinImageClick() + }} + > + + + + + + + + + + )} + Promise isComplete: boolean + onPinImageFromUrl?: (url: string) => void } -export const ImageMessagesList: React.FC = memo(({ device, images, getMessagesPagination }) => { +export const ImageMessagesList: React.FC = memo(({ device, images, getMessagesPagination, onPinImageFromUrl }) => { const { showMessage } = useShowDataStore() const [modal, setModal] = useState(false) @@ -211,15 +212,18 @@ export const ImageMessagesList: React.FC = memo(({ device, images, /> )} onPinImageFromUrl(message.file!.toString()) + : undefined + } /> 30 ? message.content : ''} >