221 lines
8.3 KiB
TypeScript
221 lines
8.3 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import { CalendarDays, ImageIcon, Loader2, Search } from 'lucide-react';
|
||
import { Calendar } from '@/components/ui/calendar';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Input } from '@/components/ui/input';
|
||
import type { ChatMessage } from '@/lib/api';
|
||
import { getChatDateKey } from '@/lib/chat-date-utils';
|
||
import { getMessagePreviewText } from '@/lib/chat-message-utils';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export type ChatToolsTab = 'search' | 'calendar' | 'media';
|
||
|
||
interface ChatToolsDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
messages: ChatMessage[];
|
||
defaultTab?: ChatToolsTab;
|
||
getMessagePreview: (message: ChatMessage) => string;
|
||
getMediaUrl?: (message: ChatMessage) => string | null;
|
||
onJumpToMessage: (messageId: string) => void;
|
||
}
|
||
|
||
export function ChatToolsDialog({
|
||
open,
|
||
onOpenChange,
|
||
messages,
|
||
defaultTab = 'search',
|
||
getMessagePreview,
|
||
getMediaUrl,
|
||
onJumpToMessage
|
||
}: ChatToolsDialogProps) {
|
||
const [tab, setTab] = useState<ChatToolsTab>(defaultTab);
|
||
const [query, setQuery] = useState('');
|
||
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
||
|
||
const messageDates = useMemo(() => {
|
||
const keys = new Set<string>();
|
||
for (const message of messages) {
|
||
keys.add(getChatDateKey(message.createdAt));
|
||
}
|
||
return keys;
|
||
}, [messages]);
|
||
|
||
const searchResults = useMemo(() => {
|
||
const normalized = query.trim().toLowerCase();
|
||
if (!normalized) return [];
|
||
return messages.filter((message) => getMessagePreview(message).toLowerCase().includes(normalized));
|
||
}, [getMessagePreview, messages, query]);
|
||
|
||
const mediaMessages = useMemo(
|
||
() => messages.filter((message) => message.type === 'IMAGE' && message.storageKey),
|
||
[messages]
|
||
);
|
||
|
||
const calendarModifiers = useMemo(
|
||
() => ({
|
||
hasMessages: (date: Date) => messageDates.has(getChatDateKey(date))
|
||
}),
|
||
[messageDates]
|
||
);
|
||
|
||
function jumpToDate(date: Date) {
|
||
const key = getChatDateKey(date);
|
||
const target = messages.find((message) => getChatDateKey(message.createdAt) === key);
|
||
if (!target) return;
|
||
onJumpToMessage(target.id);
|
||
onOpenChange(false);
|
||
}
|
||
|
||
function handleOpenChange(next: boolean) {
|
||
onOpenChange(next);
|
||
if (next) setTab(defaultTab);
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||
<DialogContent className="max-h-[85vh] overflow-hidden rounded-[28px] sm:max-w-[520px]">
|
||
<DialogHeader>
|
||
<DialogTitle>Чат</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="flex gap-1 rounded-xl bg-[#f4f5f8] p-1">
|
||
{(
|
||
[
|
||
{ id: 'search' as const, label: 'Поиск', icon: Search },
|
||
{ id: 'calendar' as const, label: 'Дата', icon: CalendarDays },
|
||
{ id: 'media' as const, label: 'Медиа', icon: ImageIcon }
|
||
] as const
|
||
).map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => setTab(item.id)}
|
||
className={cn(
|
||
'flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium transition',
|
||
tab === item.id ? 'bg-white text-[#1f2430] shadow-sm' : 'text-[#667085] hover:text-[#1f2430]'
|
||
)}
|
||
>
|
||
<item.icon className="h-3.5 w-3.5" />
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="max-h-[min(52vh,420px)] overflow-y-auto pt-1">
|
||
{tab === 'search' ? (
|
||
<div className="space-y-3">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a8adbc]" />
|
||
<Input
|
||
value={query}
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
placeholder="Поиск по сообщениям"
|
||
className="rounded-xl pl-9"
|
||
/>
|
||
</div>
|
||
{!query.trim() ? (
|
||
<p className="py-6 text-center text-sm text-[#667085]">Введите текст для поиска</p>
|
||
) : searchResults.length ? (
|
||
<div className="space-y-1">
|
||
{searchResults.map((message) => (
|
||
<button
|
||
key={message.id}
|
||
type="button"
|
||
className="flex w-full flex-col rounded-xl px-3 py-2 text-left transition hover:bg-[#f4f5f8]"
|
||
onClick={() => {
|
||
onJumpToMessage(message.id);
|
||
onOpenChange(false);
|
||
}}
|
||
>
|
||
<span className="text-xs font-medium text-[#3390ec]">{message.senderName}</span>
|
||
<span className="truncate text-sm text-[#1f2430]">{getMessagePreview(message)}</span>
|
||
<span className="text-[11px] text-[#a8adbc]">
|
||
{new Intl.DateTimeFormat('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}).format(new Date(message.createdAt))}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-6 text-center text-sm text-[#667085]">Ничего не найдено</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{tab === 'calendar' ? (
|
||
<div className="space-y-3">
|
||
<Calendar
|
||
mode="single"
|
||
selected={selectedDate}
|
||
onSelect={(date) => {
|
||
setSelectedDate(date);
|
||
if (date) jumpToDate(date);
|
||
}}
|
||
modifiers={calendarModifiers}
|
||
modifiersClassNames={{
|
||
hasMessages: 'relative after:absolute after:bottom-1 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-[#3390ec]'
|
||
}}
|
||
/>
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-medium text-[#667085]">Перейти к дате</label>
|
||
<Input
|
||
type="date"
|
||
onChange={(event) => {
|
||
if (!event.target.value) return;
|
||
jumpToDate(new Date(`${event.target.value}T12:00:00`));
|
||
}}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-[#667085]">Точки под датами означают наличие сообщений в этот день</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{tab === 'media' ? (
|
||
mediaMessages.length ? (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{mediaMessages.map((message) => {
|
||
const url = getMediaUrl?.(message);
|
||
return (
|
||
<button
|
||
key={message.id}
|
||
type="button"
|
||
className="relative aspect-square overflow-hidden rounded-xl bg-[#eef1f6]"
|
||
onClick={() => {
|
||
onJumpToMessage(message.id);
|
||
onOpenChange(false);
|
||
}}
|
||
>
|
||
{url ? (
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={url} alt="" className="h-full w-full object-cover" />
|
||
) : (
|
||
<div className="flex h-full items-center justify-center">
|
||
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
|
||
</div>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="py-8 text-center text-sm text-[#667085]">Медиафайлов пока нет</p>
|
||
)
|
||
) : null}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export function buildMessagePreview(message: ChatMessage, visibleText?: string) {
|
||
return getMessagePreviewText(message, visibleText);
|
||
}
|