update
This commit is contained in:
366
apps/frontend/components/chat/chat-media-compose-dialog.tsx
Normal file
366
apps/frontend/components/chat/chat-media-compose-dialog.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2, Plus, Smile, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { createFilePreviewUrl, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress';
|
||||
import { formatFileSize } from '@/lib/chat-media';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ChatMediaComposeItem {
|
||||
id: string;
|
||||
file: File;
|
||||
previewUrl: string | null;
|
||||
sendAsFile: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const MAX_FILES = 10;
|
||||
|
||||
function createComposeItem(file: File, sendAsFile = false): ChatMediaComposeItem {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
previewUrl: createFilePreviewUrl(file),
|
||||
sendAsFile
|
||||
};
|
||||
}
|
||||
|
||||
export function useChatMediaComposer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<ChatMediaComposeItem[]>([]);
|
||||
const [caption, setCaption] = useState('');
|
||||
const [sendAsFile, setSendAsFile] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setItems((current) => {
|
||||
current.forEach((item) => {
|
||||
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||||
});
|
||||
return [];
|
||||
});
|
||||
setCaption('');
|
||||
setSendAsFile(false);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const openWithFiles = useCallback(async (files: File[], options?: { sendAsFile?: boolean }) => {
|
||||
const limited = files.slice(0, MAX_FILES);
|
||||
const initialSendAsFile = options?.sendAsFile ?? false;
|
||||
const nextItems = await Promise.all(
|
||||
limited.map(async (file) => {
|
||||
const item = createComposeItem(file, initialSendAsFile);
|
||||
if (isCompressibleImageFile(file)) {
|
||||
const dimensions = await readImageDimensions(file);
|
||||
item.width = dimensions.width;
|
||||
item.height = dimensions.height;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
setItems(nextItems);
|
||||
setSendAsFile(initialSendAsFile);
|
||||
setCaption('');
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const limited = files.slice(0, MAX_FILES);
|
||||
if (!limited.length) return;
|
||||
|
||||
const appended = await Promise.all(
|
||||
limited.map(async (file) => {
|
||||
const item = createComposeItem(file, sendAsFile);
|
||||
if (isCompressibleImageFile(file)) {
|
||||
const dimensions = await readImageDimensions(file);
|
||||
item.width = dimensions.width;
|
||||
item.height = dimensions.height;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
setItems((current) => [...current, ...appended].slice(0, MAX_FILES));
|
||||
}, [sendAsFile]);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((current) => {
|
||||
const target = current.find((item) => item.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
const next = current.filter((item) => item.id !== id);
|
||||
if (!next.length) {
|
||||
setOpen(false);
|
||||
setCaption('');
|
||||
setSendAsFile(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setItems((current) => current.map((item) => ({ ...item, sendAsFile })));
|
||||
}, [sendAsFile]);
|
||||
|
||||
const hasImages = useMemo(() => items.some((item) => isCompressibleImageFile(item.file)), [items]);
|
||||
|
||||
return {
|
||||
open,
|
||||
items,
|
||||
caption,
|
||||
sendAsFile,
|
||||
hasImages,
|
||||
maxFiles: MAX_FILES,
|
||||
setCaption,
|
||||
setSendAsFile,
|
||||
openWithFiles,
|
||||
addFiles,
|
||||
removeItem,
|
||||
close
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatMediaComposeDialogProps {
|
||||
open: boolean;
|
||||
items: ChatMediaComposeItem[];
|
||||
caption: string;
|
||||
sendAsFile: boolean;
|
||||
hasImages: boolean;
|
||||
maxFiles: number;
|
||||
sending?: boolean;
|
||||
onCaptionChange: (value: string) => void;
|
||||
onSendAsFileChange: (value: boolean) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onAddFiles: (files: File[]) => void;
|
||||
onClose: () => void;
|
||||
onSend: () => void;
|
||||
}
|
||||
|
||||
export function ChatMediaComposeDialog({
|
||||
open,
|
||||
items,
|
||||
caption,
|
||||
sendAsFile,
|
||||
hasImages,
|
||||
maxFiles,
|
||||
sending = false,
|
||||
onCaptionChange,
|
||||
onSendAsFileChange,
|
||||
onRemoveItem,
|
||||
onAddFiles,
|
||||
onClose,
|
||||
onSend
|
||||
}: ChatMediaComposeDialogProps) {
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const activePreview = items[0] ?? null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="overflow-hidden rounded-[20px] p-0 sm:max-w-[460px]">
|
||||
<div className="border-b border-[#eceef4] px-5 py-4">
|
||||
<DialogHeader className="mb-0">
|
||||
<DialogTitle>{items.length > 1 ? `Отправить ${items.length} файла` : 'Отправить изображение'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
{activePreview ? (
|
||||
<div className="relative overflow-hidden rounded-2xl bg-[#0f1117]">
|
||||
{activePreview.previewUrl ? (
|
||||
// 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
|
||||
type="button"
|
||||
className="absolute right-3 top-3 rounded-full bg-black/55 p-1.5 text-white transition hover:bg-black/70"
|
||||
aria-label="Удалить файл"
|
||||
onClick={() => onRemoveItem(activePreview.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.length > 1 ? (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="group relative aspect-square overflow-hidden rounded-xl bg-[#eef1f6]"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
title="Нажмите, чтобы удалить"
|
||||
>
|
||||
{item.previewUrl ? (
|
||||
// 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" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : activePreview?.previewUrl ? (
|
||||
<p className="text-center text-xs text-[#667085]">Для редактирования нажмите на изображение</p>
|
||||
) : null}
|
||||
|
||||
{hasImages ? (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl bg-[#f4f5f8] px-3 py-2.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendAsFile}
|
||||
onChange={(event) => onSendAsFileChange(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-[#cfd5df]"
|
||||
/>
|
||||
<span>Отправить как файл</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={caption}
|
||||
onChange={(event) => onCaptionChange(event.target.value)}
|
||||
placeholder="Подпись"
|
||||
className="rounded-xl pr-10"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Smile className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#98a2b3]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#eceef4] px-5 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-[#3390ec]"
|
||||
disabled={items.length >= maxFiles || sending}
|
||||
onClick={() => addInputRef.current?.click()}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" disabled={sending} onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" disabled={!items.length || sending} onClick={onSend}>
|
||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Отправить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={addInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) onAddFiles(files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMediaDropOverlayProps {
|
||||
visible: boolean;
|
||||
onDropCompressed: (files: File[]) => void;
|
||||
onDropAsFile: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function ChatMediaDropOverlay({ visible, onDropCompressed, onDropAsFile }: ChatMediaDropOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex flex-col gap-3 bg-[#0f1117]/78 p-4 backdrop-blur-[1px]">
|
||||
<DropZone
|
||||
title="Перетащите сюда фотографии"
|
||||
subtitle="для отправки их без сжатия"
|
||||
className="flex-1 border-white/20 bg-white/5 text-white/90"
|
||||
onDrop={onDropAsFile}
|
||||
/>
|
||||
<DropZone
|
||||
title="Перетащите сюда фотографии"
|
||||
subtitle="для быстрой отправки"
|
||||
className="flex-1 border-[#3390ec]/40 bg-[#3390ec]/10 text-[#dbeafe]"
|
||||
onDrop={onDropCompressed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropZone({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
onDrop
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
className?: string;
|
||||
onDrop: (files: File[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto flex flex-col items-center justify-center rounded-[24px] border-2 border-dashed px-6 text-center transition',
|
||||
className
|
||||
)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length) onDrop(files);
|
||||
}}
|
||||
>
|
||||
<p className="text-lg font-medium">{title}</p>
|
||||
<p className="mt-1 text-sm opacity-80">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function extractFilesFromDataTransfer(dataTransfer: DataTransfer | null) {
|
||||
if (!dataTransfer) return [] as File[];
|
||||
return Array.from(dataTransfer.files ?? []);
|
||||
}
|
||||
|
||||
export function extractFilesFromClipboard(dataTransfer: DataTransfer | null) {
|
||||
if (!dataTransfer) return [] as File[];
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(dataTransfer.items ?? [])) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
if (files.length) return files;
|
||||
return Array.from(dataTransfer.files ?? []);
|
||||
}
|
||||
Reference in New Issue
Block a user