Files
IdP/apps/frontend/components/chat/chat-media-compose-dialog.tsx
lendry 7fc3ca7952 fix
2026-07-10 12:37:54 +03:00

392 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, getComposeDialogTitle, getComposeMediaKind, type ComposeMediaKind } from '@/lib/chat-media';
import { cn } from '@/lib/utils';
export interface ChatMediaComposeItem {
id: string;
file: File;
previewUrl: string | null;
mediaKind: ComposeMediaKind;
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),
mediaKind: getComposeMediaKind(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;
const dialogTitle = getComposeDialogTitle(items.map((item) => item.file));
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>{dialogTitle}</DialogTitle>
</DialogHeader>
</div>
<div className="space-y-4 px-5 py-4">
{activePreview ? (
<div className="relative overflow-hidden rounded-2xl bg-[#0f1117]">
<ComposeMediaPreview item={activePreview} className="mx-auto max-h-[320px] w-full object-contain" />
<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="Нажмите, чтобы удалить"
>
<ComposeMediaPreview item={item} className="h-full w-full object-cover" thumbnail />
<span className="absolute inset-0 bg-black/0 transition group-hover:bg-black/20" />
</button>
))}
</div>
) : activePreview?.mediaKind === 'image' && activePreview.previewUrl ? (
<p className="text-center text-xs text-[#667085]">Для редактирования нажмите на изображение</p>
) : activePreview?.mediaKind === 'video' ? (
<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="*/*"
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 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({
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 ?? []);
}