This commit is contained in:
lendry
2026-07-10 12:37:54 +03:00
parent f00f3d411d
commit 7fc3ca7952
3 changed files with 77 additions and 23 deletions

View File

@@ -6,13 +6,14 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { createFilePreviewUrl, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress'; import { createFilePreviewUrl, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress';
import { formatFileSize } from '@/lib/chat-media'; import { formatFileSize, getComposeDialogTitle, getComposeMediaKind, type ComposeMediaKind } from '@/lib/chat-media';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface ChatMediaComposeItem { export interface ChatMediaComposeItem {
id: string; id: string;
file: File; file: File;
previewUrl: string | null; previewUrl: string | null;
mediaKind: ComposeMediaKind;
sendAsFile: boolean; sendAsFile: boolean;
width?: number; width?: number;
height?: number; height?: number;
@@ -25,6 +26,7 @@ function createComposeItem(file: File, sendAsFile = false): ChatMediaComposeItem
id: crypto.randomUUID(), id: crypto.randomUUID(),
file, file,
previewUrl: createFilePreviewUrl(file), previewUrl: createFilePreviewUrl(file),
mediaKind: getComposeMediaKind(file),
sendAsFile sendAsFile
}; };
} }
@@ -159,32 +161,21 @@ export function ChatMediaComposeDialog({
}: ChatMediaComposeDialogProps) { }: ChatMediaComposeDialogProps) {
const addInputRef = useRef<HTMLInputElement>(null); const addInputRef = useRef<HTMLInputElement>(null);
const activePreview = items[0] ?? null; const activePreview = items[0] ?? null;
const dialogTitle = getComposeDialogTitle(items.map((item) => item.file));
return ( return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}> <Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="overflow-hidden rounded-[20px] p-0 sm:max-w-[460px]"> <DialogContent className="overflow-hidden rounded-[20px] p-0 sm:max-w-[460px]">
<div className="border-b border-[#eceef4] px-5 py-4"> <div className="border-b border-[#eceef4] px-5 py-4">
<DialogHeader className="mb-0"> <DialogHeader className="mb-0">
<DialogTitle>{items.length > 1 ? `Отправить ${items.length} файла` : 'Отправить изображение'}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader> </DialogHeader>
</div> </div>
<div className="space-y-4 px-5 py-4"> <div className="space-y-4 px-5 py-4">
{activePreview ? ( {activePreview ? (
<div className="relative overflow-hidden rounded-2xl bg-[#0f1117]"> <div className="relative overflow-hidden rounded-2xl bg-[#0f1117]">
{activePreview.previewUrl ? ( <ComposeMediaPreview item={activePreview} className="mx-auto max-h-[320px] w-full object-contain" />
// eslint-disable-next-line @next/next/no-img-element
<img
src={activePreview.previewUrl}
alt={activePreview.file.name}
className="mx-auto max-h-[320px] w-full object-contain"
/>
) : (
<div className="flex min-h-[180px] flex-col items-center justify-center gap-2 px-6 text-center text-white/80">
<p className="text-sm font-medium">{activePreview.file.name}</p>
<p className="text-xs text-white/55">{formatFileSize(activePreview.file.size)}</p>
</div>
)}
<button <button
type="button" type="button"
className="absolute right-3 top-3 rounded-full bg-black/55 p-1.5 text-white transition hover:bg-black/70" className="absolute right-3 top-3 rounded-full bg-black/55 p-1.5 text-white transition hover:bg-black/70"
@@ -206,18 +197,15 @@ export function ChatMediaComposeDialog({
onClick={() => onRemoveItem(item.id)} onClick={() => onRemoveItem(item.id)}
title="Нажмите, чтобы удалить" title="Нажмите, чтобы удалить"
> >
{item.previewUrl ? ( <ComposeMediaPreview item={item} className="h-full w-full object-cover" thumbnail />
// eslint-disable-next-line @next/next/no-img-element
<img src={item.previewUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-1 text-[10px] text-[#667085]">{item.file.name.slice(0, 8)}</div>
)}
<span className="absolute inset-0 bg-black/0 transition group-hover:bg-black/20" /> <span className="absolute inset-0 bg-black/0 transition group-hover:bg-black/20" />
</button> </button>
))} ))}
</div> </div>
) : activePreview?.previewUrl ? ( ) : activePreview?.mediaKind === 'image' && activePreview.previewUrl ? (
<p className="text-center text-xs text-[#667085]">Для редактирования нажмите на изображение</p> <p className="text-center text-xs text-[#667085]">Для редактирования нажмите на изображение</p>
) : activePreview?.mediaKind === 'video' ? (
<p className="text-center text-xs text-[#667085]">Проверьте видео перед отправкой</p>
) : null} ) : null}
{hasImages ? ( {hasImages ? (
@@ -314,6 +302,43 @@ export function ChatMediaDropOverlay({ visible, onDropCompressed, onDropAsFile }
); );
} }
function ComposeMediaPreview({
item,
className,
thumbnail = false
}: {
item: ChatMediaComposeItem;
className?: string;
thumbnail?: boolean;
}) {
if (item.mediaKind === 'video' && item.previewUrl) {
return (
<video
src={item.previewUrl}
className={cn('bg-black', className)}
controls={!thumbnail}
playsInline
muted={thumbnail}
preload="metadata"
/>
);
}
if (item.mediaKind === 'image' && item.previewUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.previewUrl} alt={item.file.name} className={className} />
);
}
return (
<div className="flex min-h-[180px] flex-col items-center justify-center gap-2 px-6 text-center text-white/80">
<p className="text-sm font-medium">{item.file.name}</p>
<p className="text-xs text-white/55">{formatFileSize(item.file.size)}</p>
</div>
);
}
function DropZone({ function DropZone({
title, title,
subtitle, subtitle,

View File

@@ -1,3 +1,5 @@
import { resolveChatContentType } from '@/lib/chat-media';
const DEFAULT_MAX_EDGE = 2560; const DEFAULT_MAX_EDGE = 2560;
const DEFAULT_QUALITY = 0.82; const DEFAULT_QUALITY = 0.82;
@@ -107,7 +109,8 @@ export async function readImageDimensions(file: File) {
} }
export function createFilePreviewUrl(file: File) { export function createFilePreviewUrl(file: File) {
if (file.type.startsWith('image/')) { const contentType = resolveChatContentType(file);
if (contentType.startsWith('image/') || contentType.startsWith('video/')) {
return URL.createObjectURL(file); return URL.createObjectURL(file);
} }
return null; return null;

View File

@@ -134,6 +134,32 @@ export function isVideoFile(file: Pick<File, 'type' | 'name'>) {
return resolveChatContentType(file).startsWith('video/'); return resolveChatContentType(file).startsWith('video/');
} }
export type ComposeMediaKind = 'image' | 'video' | 'audio' | 'file';
export function getComposeMediaKind(file: Pick<File, 'type' | 'name'>): ComposeMediaKind {
const contentType = resolveChatContentType(file);
if (contentType.startsWith('image/')) return 'image';
if (contentType.startsWith('video/')) return 'video';
if (contentType.startsWith('audio/')) return 'audio';
return 'file';
}
export function getComposeDialogTitle(files: Array<Pick<File, 'type' | 'name'>>) {
if (!files.length) return 'Отправить файл';
if (files.length > 1) return `Отправить ${files.length} файла`;
switch (getComposeMediaKind(files[0]!)) {
case 'video':
return 'Отправить видео';
case 'audio':
return 'Отправить аудио';
case 'image':
return 'Отправить изображение';
default:
return 'Отправить файл';
}
}
export function formatFileSize(bytes?: number) { export function formatFileSize(bytes?: number) {
if (!bytes || bytes <= 0) return ''; if (!bytes || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} Б`; if (bytes < 1024) return `${bytes} Б`;