1219 lines
50 KiB
TypeScript
1219 lines
50 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Send,
|
|
Paperclip,
|
|
Smile,
|
|
Mic,
|
|
X,
|
|
Reply,
|
|
Pencil,
|
|
Image as ImageIcon,
|
|
FileText,
|
|
Music,
|
|
Clock,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Calendar,
|
|
Check,
|
|
} from 'lucide-react';
|
|
import { useChatStore } from '../../application/chatStore';
|
|
import { useAuthStore } from '../../../auth/application/authStore';
|
|
import { ChatApi } from '../../infrastructure/chatApi';
|
|
import { getSocket } from '../../../../core/infrastructure/socket';
|
|
import { useLang } from '../../../../core/infrastructure/i18n';
|
|
import { AUDIO_EXTENSIONS, MAX_FILE_SIZE, type ChatMember } from '../../../../core/domain/types';
|
|
import { useNotificationStore } from '../../../../core/application/stores/notificationStore';
|
|
import EmojiPicker from './EmojiPicker';
|
|
|
|
interface Attachment {
|
|
file: File;
|
|
preview?: string;
|
|
type: 'image' | 'video' | 'file' | 'audio';
|
|
}
|
|
|
|
interface MessageInputProps {
|
|
chatId: string;
|
|
}
|
|
|
|
export default function MessageInput({ chatId }: MessageInputProps) {
|
|
const { user } = useAuthStore();
|
|
const { t } = useLang();
|
|
const { replyTo, editingMessage, setReplyTo, setEditingMessage, getDraft, setDraft, chats } = useChatStore();
|
|
const [text, setText] = useState(() => getDraft(chatId));
|
|
|
|
// Get current chat members for @mentions
|
|
const chat = chats.find(c => c.id === chatId);
|
|
const isGroup = chat?.type === 'group';
|
|
const chatMembers = (chat?.members || []).filter((m) => m.user.id !== user?.id);
|
|
const [showEmoji, setShowEmoji] = useState(false);
|
|
const [isRecording, setIsRecording] = useState(false);
|
|
const [recordingTime, setRecordingTime] = useState(0);
|
|
const [showAttachMenu, setShowAttachMenu] = useState(false);
|
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [formatMenu, setFormatMenu] = useState<{ show: boolean; x: number; y: number }>({ show: false, x: 0, y: 0 });
|
|
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
|
const [mentionIndex, setMentionIndex] = useState(0);
|
|
const [showSchedule, setShowSchedule] = useState(false);
|
|
const [scheduleDate, setScheduleDate] = useState('');
|
|
const [scheduleStep, setScheduleStep] = useState<'presets' | 'custom'>('presets');
|
|
const [scheduleHour, setScheduleHour] = useState('12');
|
|
const [scheduleMinute, setScheduleMinute] = useState('00');
|
|
const [scheduleCalDate, setScheduleCalDate] = useState(''); // YYYY-MM-DD
|
|
const [scheduleCalMonth, setScheduleCalMonth] = useState(new Date().getMonth());
|
|
const [scheduleCalYear, setScheduleCalYear] = useState(new Date().getFullYear());
|
|
const [scheduleToast, setScheduleToast] = useState<string | null>(null);
|
|
|
|
// Filtered members for @mention
|
|
const filteredMembers = mentionQuery !== null && isGroup
|
|
? chatMembers.filter((m) => {
|
|
const q = mentionQuery.toLowerCase();
|
|
return (m.user.displayName || '').toLowerCase().includes(q)
|
|
|| (m.user.userName || '').toLowerCase().includes(q)
|
|
|| (m.user.username || '').toLowerCase().includes(q);
|
|
}).slice(0, 6)
|
|
: [];
|
|
|
|
const insertMention = (member: ChatMember) => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
const username = member.user.userName || member.user.username;
|
|
if (!username) return;
|
|
const cursorPos = el.selectionStart;
|
|
const before = text.substring(0, cursorPos);
|
|
const after = text.substring(cursorPos);
|
|
// Find the @ that started this mention
|
|
const atIdx = before.lastIndexOf('@');
|
|
if (atIdx === -1) return;
|
|
const newText = before.substring(0, atIdx) + `@${username} ` + after;
|
|
setText(newText);
|
|
setDraft(chatId, newText);
|
|
setMentionQuery(null);
|
|
setMentionIndex(0);
|
|
setTimeout(() => {
|
|
el.focus();
|
|
const newPos = atIdx + username.length + 2;
|
|
el.setSelectionRange(newPos, newPos);
|
|
}, 0);
|
|
};
|
|
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const animFrameRef = useRef<number>(0);
|
|
const recordingTimeRef = useRef<number>(0);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const [liveBars, setLiveBars] = useState<number[]>(() => Array(32).fill(5));
|
|
|
|
// Cleanup recording resources on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
if (audioContextRef.current) audioContextRef.current.close().catch(() => {});
|
|
if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop());
|
|
if (mediaRecorderRef.current?.state === 'recording') {
|
|
mediaRecorderRef.current.stop();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Автоподгон высоты textarea
|
|
useEffect(() => {
|
|
const el = inputRef.current;
|
|
if (el) {
|
|
el.style.height = '22px'; // Reset/Min height
|
|
if (text) {
|
|
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
|
|
}
|
|
}
|
|
}, [text]);
|
|
|
|
// При редактировании — заполнить текст
|
|
useEffect(() => {
|
|
if (editingMessage?.content) {
|
|
setText(editingMessage.content);
|
|
inputRef.current?.focus();
|
|
}
|
|
}, [editingMessage]);
|
|
|
|
// При ответе - фокус на поле ввода
|
|
useEffect(() => {
|
|
if (replyTo) {
|
|
inputRef.current?.focus();
|
|
}
|
|
}, [replyTo]);
|
|
|
|
// Load draft when switching chats
|
|
useEffect(() => {
|
|
if (!editingMessage) {
|
|
setText(getDraft(chatId));
|
|
}
|
|
}, [chatId]);
|
|
|
|
// Cleanup preview URLs
|
|
useEffect(() => {
|
|
(window as any).hasUnsavedAttachments = attachments.length > 0;
|
|
return () => {
|
|
attachments.forEach(a => {
|
|
if (a.preview) URL.revokeObjectURL(a.preview);
|
|
});
|
|
(window as any).hasUnsavedAttachments = false;
|
|
};
|
|
}, [attachments]);
|
|
|
|
// Typing events
|
|
const emitTyping = useCallback(() => {
|
|
const socket = getSocket();
|
|
if (!socket) return;
|
|
socket.emit('typing_start', chatId);
|
|
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
|
typingTimeoutRef.current = setTimeout(() => {
|
|
socket.emit('typing_stop', chatId);
|
|
}, 2000);
|
|
}, [chatId]);
|
|
|
|
const handleSend = async (scheduledAt?: string) => {
|
|
const trimmed = text.trim();
|
|
const hasAttachments = attachments.length > 0;
|
|
|
|
if (!trimmed && !hasAttachments) return;
|
|
if (isSending) return;
|
|
|
|
const socket = getSocket();
|
|
if (!socket) return;
|
|
|
|
// Остановить typing
|
|
socket.emit('typing_stop', chatId);
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
|
|
|
if (editingMessage) {
|
|
socket.emit('edit_message', {
|
|
messageId: editingMessage.id,
|
|
content: trimmed,
|
|
chatId,
|
|
});
|
|
setEditingMessage(null);
|
|
setText('');
|
|
setDraft(chatId, '');
|
|
return;
|
|
}
|
|
|
|
if (hasAttachments) {
|
|
setIsSending(true);
|
|
try {
|
|
const uploadPromises = attachments.map(a => ChatApi.uploadFile(a.file));
|
|
const results = await Promise.all(uploadPromises);
|
|
|
|
const socketAttachments = results.map((res, i) => ({
|
|
type: attachments[i].type,
|
|
url: res.url,
|
|
fileName: res.filename,
|
|
fileSize: res.size
|
|
}));
|
|
|
|
socket.emit('send_message', {
|
|
chatId,
|
|
content: trimmed || null,
|
|
type: attachments.length > 0 ? (attachments.every(a => a.type === 'image') ? 'image' : 'file') : 'text',
|
|
attachments: socketAttachments,
|
|
replyToId: replyTo?.id || null,
|
|
quote: replyTo?.quote || null,
|
|
...(scheduledAt ? { scheduledAt } : {}),
|
|
});
|
|
|
|
setReplyTo(null);
|
|
clearAttachments();
|
|
} catch (e) {
|
|
console.error('Ошибка загрузки файла:', e);
|
|
const { addNotification } = useNotificationStore.getState();
|
|
addNotification('error', t('uploadError') || 'Ошибка загрузки файла');
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
} else {
|
|
socket.emit('send_message', {
|
|
chatId,
|
|
content: trimmed,
|
|
type: 'text',
|
|
replyToId: replyTo?.id || null,
|
|
quote: replyTo?.quote || null,
|
|
...(scheduledAt ? { scheduledAt } : {}),
|
|
});
|
|
setReplyTo(null);
|
|
}
|
|
|
|
setText('');
|
|
setDraft(chatId, '');
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
// Handle @mention navigation
|
|
if (mentionQuery !== null && filteredMembers.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setMentionIndex(i => (i + 1) % filteredMembers.length);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setMentionIndex(i => (i - 1 + filteredMembers.length) % filteredMembers.length);
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
insertMention(filteredMembers[mentionIndex]);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setMentionQuery(null);
|
|
return;
|
|
}
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const clearAttachments = () => {
|
|
attachments.forEach(a => {
|
|
if (a.preview) URL.revokeObjectURL(a.preview);
|
|
});
|
|
setAttachments([]);
|
|
};
|
|
|
|
const removeAttachment = (index: number) => {
|
|
setAttachments(prev => {
|
|
const newArr = [...prev];
|
|
if (newArr[index].preview) URL.revokeObjectURL(newArr[index].preview!);
|
|
newArr.splice(index, 1);
|
|
return newArr;
|
|
});
|
|
};
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
const { addNotification } = useNotificationStore.getState();
|
|
const newAttachments: Attachment[] = [];
|
|
|
|
let tooLarge = false;
|
|
let limitExceeded = false;
|
|
|
|
for (const file of files) {
|
|
if (attachments.length + newAttachments.length >= 20) {
|
|
limitExceeded = true;
|
|
break;
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
tooLarge = true;
|
|
continue;
|
|
}
|
|
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
|
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
|
}
|
|
|
|
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
|
|
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
|
|
|
setAttachments(prev => [...prev, ...newAttachments]);
|
|
inputRef.current?.focus();
|
|
}
|
|
e.target.value = '';
|
|
setShowAttachMenu(false);
|
|
};
|
|
|
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
const { addNotification } = useNotificationStore.getState();
|
|
const newAttachments: Attachment[] = [];
|
|
|
|
let limitExceeded = false;
|
|
|
|
for (const file of files) {
|
|
if (attachments.length + newAttachments.length >= 20) {
|
|
limitExceeded = true;
|
|
break;
|
|
}
|
|
const isVideo = file.type.startsWith('video/');
|
|
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
|
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
|
|
}
|
|
|
|
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
|
|
|
setAttachments(prev => [...prev, ...newAttachments]);
|
|
inputRef.current?.focus();
|
|
}
|
|
e.target.value = '';
|
|
setShowAttachMenu(false);
|
|
};
|
|
|
|
// Запись голосового
|
|
const startRecording = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
streamRef.current = stream;
|
|
// Use ogg/opus for better compatibility, fallback to webm
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
|
|
? 'audio/ogg;codecs=opus'
|
|
: MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
|
? 'audio/webm;codecs=opus'
|
|
: 'audio/webm';
|
|
const ext = mimeType.includes('ogg') ? 'ogg' : 'webm';
|
|
const recorder = new MediaRecorder(stream, { mimeType });
|
|
mediaRecorderRef.current = recorder;
|
|
chunksRef.current = [];
|
|
|
|
// Set up AnalyserNode for live waveform
|
|
const actx = new AudioContext();
|
|
const source = actx.createMediaStreamSource(stream);
|
|
const analyser = actx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
analyser.smoothingTimeConstant = 0.6;
|
|
source.connect(analyser);
|
|
audioContextRef.current = actx;
|
|
analyserRef.current = analyser;
|
|
|
|
const timeDomainData = new Uint8Array(analyser.frequencyBinCount);
|
|
const updateBars = () => {
|
|
if (!analyserRef.current) return;
|
|
analyserRef.current.getByteTimeDomainData(timeDomainData);
|
|
// Downsample to 32 bars
|
|
const bars: number[] = [];
|
|
const step = Math.floor(timeDomainData.length / 32);
|
|
for (let i = 0; i < 32; i++) {
|
|
let sum = 0;
|
|
for (let j = 0; j < step; j++) {
|
|
const val = Math.abs(timeDomainData[i * step + j] - 128);
|
|
sum += val;
|
|
}
|
|
const avg = sum / step;
|
|
// Map 0-128 to 8-100 with some exaggeration for visibility
|
|
bars.push(Math.max(8, Math.min(100, avg * 1.8 + 8)));
|
|
}
|
|
setLiveBars(bars);
|
|
animFrameRef.current = requestAnimationFrame(updateBars);
|
|
};
|
|
animFrameRef.current = requestAnimationFrame(updateBars);
|
|
|
|
recorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
};
|
|
|
|
recorder.onstop = async () => {
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
streamRef.current = null;
|
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
|
const file = new File([blob], `voice.${ext}`, { type: mimeType });
|
|
|
|
try {
|
|
const result = await ChatApi.uploadFile(file);
|
|
const socket = getSocket();
|
|
if (socket) {
|
|
socket.emit('send_message', {
|
|
chatId,
|
|
content: null,
|
|
type: 'voice',
|
|
attachments: [{
|
|
type: 'voice',
|
|
url: result.url,
|
|
fileName: result.filename,
|
|
fileSize: result.size
|
|
}],
|
|
replyToId: replyTo?.id || null,
|
|
quote: replyTo?.quote || null,
|
|
});
|
|
setReplyTo(null);
|
|
}
|
|
} catch (e) {
|
|
console.error('Ошибка отправки голосового:', e);
|
|
}
|
|
};
|
|
|
|
recorder.start();
|
|
setIsRecording(true);
|
|
setRecordingTime(0);
|
|
recordingTimeRef.current = 0;
|
|
timerRef.current = setInterval(() => {
|
|
recordingTimeRef.current += 1;
|
|
setRecordingTime((t) => t + 1);
|
|
}, 1000);
|
|
} catch (e) {
|
|
console.error('Ошибка записи:', e);
|
|
}
|
|
};
|
|
|
|
const cleanupAnalyser = () => {
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
analyserRef.current = null;
|
|
if (audioContextRef.current) {
|
|
audioContextRef.current.close().catch(() => {});
|
|
audioContextRef.current = null;
|
|
}
|
|
setLiveBars(Array(32).fill(5));
|
|
};
|
|
|
|
const stopRecording = () => {
|
|
if (mediaRecorderRef.current?.state === 'recording') {
|
|
mediaRecorderRef.current.stop();
|
|
}
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
cleanupAnalyser();
|
|
setIsRecording(false);
|
|
setRecordingTime(0);
|
|
// recordingTimeRef is consumed in onstop, don't reset here
|
|
};
|
|
|
|
const cancelRecording = () => {
|
|
if (mediaRecorderRef.current?.state === 'recording') {
|
|
mediaRecorderRef.current.ondataavailable = null;
|
|
mediaRecorderRef.current.onstop = null;
|
|
mediaRecorderRef.current.stop();
|
|
mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop());
|
|
streamRef.current = null;
|
|
}
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
cleanupAnalyser();
|
|
setIsRecording(false);
|
|
setRecordingTime(0);
|
|
recordingTimeRef.current = 0;
|
|
};
|
|
|
|
const formatTime = (sec: number) => {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const handleInputContextMenu = (e: React.MouseEvent) => {
|
|
const el = inputRef.current;
|
|
if (el && el.selectionStart !== el.selectionEnd) {
|
|
e.preventDefault();
|
|
setFormatMenu({ show: true, x: e.clientX, y: e.clientY });
|
|
}
|
|
};
|
|
|
|
const applyFormat = (prefix: string, suffix: string) => {
|
|
const el = inputRef.current;
|
|
if (!el) return;
|
|
const start = el.selectionStart;
|
|
const end = el.selectionEnd;
|
|
const val = el.value;
|
|
const selected = val.substring(start, end);
|
|
const newVal = val.substring(0, start) + prefix + selected + suffix + val.substring(end);
|
|
setText(newVal);
|
|
setFormatMenu({ show: false, x: 0, y: 0 });
|
|
|
|
// refocus and update cursor
|
|
setTimeout(() => {
|
|
el.focus();
|
|
el.setSelectionRange(start + prefix.length, end + prefix.length);
|
|
}, 0);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(true);
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setIsDragging(false);
|
|
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
const files = Array.from(e.dataTransfer.files);
|
|
const { addNotification } = useNotificationStore.getState();
|
|
const newAttachments: Attachment[] = [];
|
|
|
|
let tooLarge = false;
|
|
let limitExceeded = false;
|
|
|
|
for (const file of files) {
|
|
if (attachments.length + newAttachments.length >= 20) {
|
|
limitExceeded = true;
|
|
break;
|
|
}
|
|
if (file.size > MAX_FILE_SIZE) {
|
|
tooLarge = true;
|
|
continue;
|
|
}
|
|
|
|
const isVideo = file.type.startsWith('video/');
|
|
const isImage = file.type.startsWith('image/');
|
|
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
|
|
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
|
|
|
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
|
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
|
|
|
newAttachments.push({ file, type, preview });
|
|
}
|
|
|
|
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Некоторые файлы слишком большие');
|
|
if (limitExceeded) addNotification('warning', 'Максимум 20 файлов');
|
|
|
|
setAttachments(prev => [...prev, ...newAttachments]);
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
const hasContent = text.trim() || attachments.length > 0;
|
|
|
|
return (
|
|
<div
|
|
className="z-10 px-6 pt-2 pb-6 flex-shrink-0 bg-transparent relative"
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
>
|
|
{/* Drag overlay */}
|
|
<AnimatePresence>
|
|
{isDragging && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="absolute inset-0 z-50 rounded-3xl mx-6 mb-6 mt-2 bg-primary/10 border-2 border-dashed border-primary/40 backdrop-blur-sm flex items-center justify-center pointer-events-none"
|
|
>
|
|
<div className="flex flex-col items-center gap-4 text-primary">
|
|
<span className="material-symbols-outlined text-5xl animate-bounce">upload_file</span>
|
|
<p className="font-bold text-lg font-headline uppercase tracking-widest">{t('dropFileHere')}</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Reply / Edit indicator */}
|
|
<AnimatePresence>
|
|
{(replyTo || editingMessage) && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0, scale: 0.98 }}
|
|
animate={{ height: 'auto', opacity: 1, scale: 1 }}
|
|
exit={{ height: 0, opacity: 0, scale: 0.98 }}
|
|
className="mb-2 max-w-4xl mx-auto overflow-hidden"
|
|
>
|
|
<div className="flex items-center gap-4 px-5 py-3 bg-surface-container-high/80 backdrop-blur-2xl border-none rounded-2xl relative shadow-xl slide-on-ice">
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1.5 h-3/5 bg-primary rounded-r-full" />
|
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
<span className="material-symbols-outlined text-primary text-[20px]">
|
|
{editingMessage ? 'edit' : 'reply'}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
|
<p className="text-[10px] font-bold tracking-widest uppercase text-primary mb-0.5">
|
|
{editingMessage
|
|
? t('editing')
|
|
: `${t('replyTo')} ${replyTo?.sender?.displayName || replyTo?.sender?.userName || replyTo?.sender?.username || ''}`}
|
|
</p>
|
|
<div className="text-[13px] text-[#efeff3] truncate opacity-90 pl-1 border-l border-white/20 ml-0.5">
|
|
{replyTo?.quote ? `«${replyTo.quote}»` : (editingMessage || replyTo)?.content || t('media') || 'Медиа'}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setReplyTo(null);
|
|
setEditingMessage(null);
|
|
setAttachments([]);
|
|
setText('');
|
|
}}
|
|
className="w-8 h-8 rounded-full flex items-center justify-center text-on-surface-variant/40 hover:text-primary hover:bg-primary/10 transition-all slide-on-ice"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Attachment previews */}
|
|
<AnimatePresence>
|
|
{attachments.length > 0 && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
|
|
animate={{ height: 'auto', opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ height: 0, opacity: 0, y: 10, scale: 0.95 }}
|
|
className="mb-2 max-w-3xl mx-auto overflow-hidden px-1.5"
|
|
>
|
|
<div className="flex flex-col gap-2 p-2 bg-white/[0.04] backdrop-blur-2xl border border-white/10 rounded-2xl shadow-xl">
|
|
<div className="flex flex-wrap gap-2">
|
|
{attachments.map((att, idx) => (
|
|
<motion.div
|
|
key={idx}
|
|
layout
|
|
className="group relative w-20 h-20 rounded-xl overflow-hidden border border-white/10 flex-shrink-0"
|
|
>
|
|
{att.preview ? (
|
|
<img
|
|
src={att.preview}
|
|
alt=""
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : att.type === 'video' ? (
|
|
<div className="w-full h-full bg-knot-500/20 flex items-center justify-center">
|
|
<ImageIcon size={20} className="text-knot-400" />
|
|
</div>
|
|
) : att.type === 'audio' ? (
|
|
<div className="w-full h-full bg-emerald-500/20 flex items-center justify-center">
|
|
<Music size={20} className="text-emerald-400" />
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-full bg-sky-500/20 flex items-center justify-center">
|
|
<FileText size={20} className="text-sky-400" />
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => removeAttachment(idx)}
|
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 bg-black/40 px-1 py-0.5 pointer-events-none">
|
|
<p className="text-[8px] text-white truncate">{att.file.name}</p>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center justify-between px-2 py-1 border-t border-white/5 mt-1">
|
|
<span className="text-[10px] text-zinc-400">
|
|
{attachments.length} {t('files')} ({ (attachments.reduce((acc, a) => acc + a.file.size, 0) / 1024 / 1024).toFixed(2) } MB)
|
|
</span>
|
|
<button
|
|
onClick={clearAttachments}
|
|
className="text-[10px] text-zinc-500 hover:text-rose-400 transition-colors"
|
|
>
|
|
{t('clearAll') || 'Очистить всё'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Recording UI */}
|
|
{isRecording ? (
|
|
<div className="flex items-center gap-4 bg-surface-container-high rounded-[2rem] px-6 py-4 w-full max-w-4xl mx-auto shadow-2xl animate-in slide-in-from-bottom-4 slide-on-ice">
|
|
<button
|
|
onClick={cancelRecording}
|
|
className="w-10 h-10 rounded-full flex items-center justify-center text-error hover:bg-error/10 transition-all slide-on-ice"
|
|
>
|
|
<span className="material-symbols-outlined">delete</span>
|
|
</button>
|
|
<div className="flex-1 flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-3 h-3 rounded-full bg-error animate-pulse shadow-[0_0_8px_rgba(244,67,54,0.5)]" />
|
|
<span className="text-sm font-bold font-mono text-on-surface tracking-tighter w-12">{formatTime(recordingTime)}</span>
|
|
</div>
|
|
<div className="flex-1 flex items-center gap-[3px] h-8 items-center">
|
|
{liveBars.map((h, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 bg-primary/40 rounded-full transition-all duration-100"
|
|
style={{ height: `${h}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={stopRecording}
|
|
className="w-12 h-12 rounded-2xl bg-primary text-on-primary flex items-center justify-center hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20 slide-on-ice"
|
|
>
|
|
<span className="material-symbols-outlined">send</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-end gap-3 w-full max-w-4xl mx-auto px-4 pb-4">
|
|
{/* Attach - Outside */}
|
|
<div className="relative flex-shrink-0 self-end mb-1">
|
|
<button
|
|
onClick={() => setShowAttachMenu(!showAttachMenu)}
|
|
className="w-10 h-10 rounded-full text-on-surface-variant/60 hover:text-primary transition-all flex items-center justify-center p-0"
|
|
>
|
|
<Paperclip size={24} strokeWidth={1.5} />
|
|
</button>
|
|
<AnimatePresence>
|
|
{showAttachMenu && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowAttachMenu(false)} />
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 15 }}
|
|
className="absolute bottom-[calc(100%+12px)] left-0 w-52 rounded-[1.5rem] glass-strong shadow-2xl z-50 p-2 border border-white/10 backdrop-blur-3xl"
|
|
>
|
|
<button
|
|
onClick={() => imageInputRef.current?.click()}
|
|
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-400/20 to-purple-500/20 flex items-center justify-center ring-1 ring-knot-400/30 group-hover:scale-110 transition-transform shadow-inner">
|
|
<ImageIcon size={18} className="text-knot-400" />
|
|
</div>
|
|
{t('photoVideo')}
|
|
</button>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group mt-1"
|
|
>
|
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-emerald-400/20 to-teal-500/20 flex items-center justify-center ring-1 ring-emerald-400/30 group-hover:scale-110 transition-transform shadow-inner">
|
|
<FileText size={18} className="text-emerald-400" />
|
|
</div>
|
|
{t('file')}
|
|
</button>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
|
<input ref={imageInputRef} type="file" multiple accept="image/*,video/*" className="hidden" onChange={handleImageChange} />
|
|
</div>
|
|
|
|
{/* Main Input Pill */}
|
|
<div className="flex-1 flex items-end gap-2 bg-[#2a2a2a] rounded-2xl px-3 py-1.5 transition-all duration-300 focus-within:bg-[#323232] shadow-sm relative min-h-[44px]">
|
|
<button
|
|
onClick={() => setShowEmoji(!showEmoji)}
|
|
className="w-8 h-8 rounded-full text-on-surface-variant/50 hover:text-primary transition-all flex items-center justify-center flex-shrink-0 mb-0.5"
|
|
>
|
|
<Smile size={20} strokeWidth={1.5} />
|
|
</button>
|
|
|
|
<div className="flex-1 relative self-center">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={text}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setText(val);
|
|
setDraft(chatId, val);
|
|
emitTyping();
|
|
if (isGroup) {
|
|
const cursorPos = e.target.selectionStart;
|
|
const match = val.substring(0, cursorPos).match(/@(\w*)$/);
|
|
if (match) { setMentionQuery(match[1]); setMentionIndex(0); }
|
|
else setMentionQuery(null);
|
|
}
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
onContextMenu={handleInputContextMenu}
|
|
rows={1}
|
|
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
|
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
|
/>
|
|
<AnimatePresence>
|
|
{mentionQuery !== null && filteredMembers.length > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 8 }}
|
|
className="absolute bottom-full left-0 right-0 mb-4 rounded-xl glass-strong shadow-2xl border border-white/10 py-1 z-50 max-h-48 overflow-y-auto"
|
|
>
|
|
{filteredMembers.map((m, i) => (
|
|
<button
|
|
key={m.user.id}
|
|
onClick={() => insertMention(m)}
|
|
className={`flex items-center gap-3 w-full px-3 py-2 text-left transition-colors ${
|
|
i === mentionIndex ? 'bg-primary/20 text-white' : 'text-zinc-300 hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[10px] font-bold">
|
|
{(m.user.displayName || m.user.userName || m.user.username || '?')[0]?.toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{m.user.displayName || m.user.userName || m.user.username || '??'}</p>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{showEmoji && (
|
|
<div className="absolute left-0 bottom-[calc(100%+20px)]">
|
|
<EmojiPicker
|
|
onSelect={(emoji) => {
|
|
setText((prev) => {
|
|
const next = prev + emoji;
|
|
setDraft(chatId, next);
|
|
return next;
|
|
});
|
|
inputRef.current?.focus();
|
|
}}
|
|
onSelectGif={(gifUrl) => {
|
|
const socket = getSocket();
|
|
if (socket) {
|
|
socket.emit('send_message', {
|
|
chatId,
|
|
content: null,
|
|
type: 'image',
|
|
attachments: [{ type: 'image', url: gifUrl, fileName: 'gif.gif', fileSize: 0 }],
|
|
});
|
|
}
|
|
setShowEmoji(false);
|
|
}}
|
|
onClose={() => setShowEmoji(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Send / Mic - Circular button outside pill */}
|
|
<div className="flex-shrink-0 relative">
|
|
{hasContent ? (
|
|
<>
|
|
<button
|
|
onClick={() => handleSend()}
|
|
onContextMenu={(e) => { e.preventDefault(); setScheduleStep('presets'); setShowSchedule(true); }}
|
|
disabled={isSending}
|
|
className="w-11 h-11 flex items-center justify-center rounded-2xl bg-primary text-white hover:brightness-110 active:scale-95 transition-all shadow-[0_4px_15px_rgba(48,150,229,0.3)] disabled:opacity-50"
|
|
>
|
|
<Send size={20} strokeWidth={2.5} className="translate-x-[1px]" />
|
|
</button>
|
|
<AnimatePresence>
|
|
{showSchedule && (
|
|
<>
|
|
<div className="fixed inset-0 z-40" onClick={() => setShowSchedule(false)} />
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.9, y: 10 }}
|
|
className="absolute bottom-[calc(100%+12px)] right-0 w-72 rounded-2xl glass-strong shadow-2xl z-50 border border-white/10 backdrop-blur-3xl overflow-hidden"
|
|
>
|
|
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
|
|
{scheduleStep === 'custom' && (
|
|
<button onClick={() => setScheduleStep('presets')} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
|
|
<ChevronLeft size={16} />
|
|
</button>
|
|
)}
|
|
<Clock size={16} className="text-knot-400" />
|
|
<span className="text-sm font-medium text-zinc-200">{t('scheduleMessage')}</span>
|
|
</div>
|
|
|
|
{scheduleStep === 'presets' ? (
|
|
<div className="p-2 space-y-1">
|
|
<button
|
|
onClick={() => {
|
|
const d = new Date(Date.now() + 3600000);
|
|
handleSend(d.toISOString());
|
|
setShowSchedule(false);
|
|
setScheduleToast(d.toLocaleString());
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-zinc-200 hover:bg-white/10 transition-colors text-left"
|
|
>
|
|
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
|
|
{t('scheduleIn1h')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const d = new Date(Date.now() + 3 * 3600000);
|
|
handleSend(d.toISOString());
|
|
setShowSchedule(false);
|
|
setScheduleToast(d.toLocaleString());
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-zinc-200 hover:bg-white/10 transition-colors text-left"
|
|
>
|
|
<Clock size={15} className="text-zinc-400 flex-shrink-0" />
|
|
{t('scheduleIn3h')}
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const d = new Date(); d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0);
|
|
handleSend(d.toISOString());
|
|
setShowSchedule(false);
|
|
setScheduleToast(d.toLocaleString());
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-zinc-200 hover:bg-white/10 transition-colors text-left"
|
|
>
|
|
<Calendar size={15} className="text-zinc-400 flex-shrink-0" />
|
|
{t('scheduleTomorrow')}
|
|
</button>
|
|
<div className="border-t border-white/5 my-1" />
|
|
<button
|
|
onClick={() => {
|
|
const now = new Date();
|
|
setScheduleCalYear(now.getFullYear());
|
|
setScheduleCalMonth(now.getMonth());
|
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
const d = String(now.getDate()).padStart(2, '0');
|
|
setScheduleCalDate(`${now.getFullYear()}-${m}-${d}`);
|
|
setScheduleHour(String(Math.min(now.getHours() + 1, 23)).padStart(2, '0'));
|
|
setScheduleMinute(String(now.getMinutes()).padStart(2, '0'));
|
|
setScheduleStep('custom');
|
|
}}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm text-knot-400 hover:bg-white/10 transition-colors text-left"
|
|
>
|
|
<Calendar size={15} className="flex-shrink-0" />
|
|
{t('scheduleCustom')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<ScheduleCalendar
|
|
calDate={scheduleCalDate} setCalDate={setScheduleCalDate}
|
|
calMonth={scheduleCalMonth} setCalMonth={setScheduleCalMonth}
|
|
calYear={scheduleCalYear} setCalYear={setScheduleCalYear}
|
|
hour={scheduleHour} setHour={setScheduleHour}
|
|
minute={scheduleMinute} setMinute={setScheduleMinute}
|
|
onSend={(iso) => { handleSend(iso); setShowSchedule(false); setScheduleToast(new Date(iso).toLocaleString()); }}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={startRecording}
|
|
className="w-11 h-11 flex items-center justify-center rounded-2xl bg-primary text-white hover:brightness-110 active:scale-95 transition-all shadow-[0_4px_15px_rgba(48,150,229,0.3)]"
|
|
>
|
|
<Mic size={22} strokeWidth={1.5} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Formatting Context Menu */}
|
|
<AnimatePresence>
|
|
{formatMenu.show && (
|
|
<>
|
|
<div className="fixed inset-0 z-50 cursor-pointer" onClick={() => setFormatMenu({ ...formatMenu, show: false })} onContextMenu={(e) => { e.preventDefault(); setFormatMenu({ ...formatMenu, show: false }); }} />
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
className="fixed z-[9999] w-48 rounded-2xl glass-strong shadow-2xl py-1"
|
|
style={{ left: formatMenu.x, top: formatMenu.y - 180 }}
|
|
>
|
|
<button
|
|
onClick={() => applyFormat('**', '**')}
|
|
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
|
|
>
|
|
<b className="font-bold">{t('formatBold')}</b> <span className="text-xs text-zinc-500 ml-auto">{t('formatBoldHint')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => applyFormat('_', '_')}
|
|
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
|
|
>
|
|
<em className="italic">{t('formatItalic')}</em> <span className="text-xs text-zinc-500 ml-auto">{t('formatItalicHint')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => applyFormat('~', '~')}
|
|
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
|
|
>
|
|
<del className="line-through">{t('formatStrike')}</del> <span className="text-xs text-zinc-500 ml-auto">{t('formatStrikeHint')}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => applyFormat('`', '`')}
|
|
className="flex items-center gap-3 w-full px-4 py-2 text-sm text-zinc-200 hover:bg-white/10 transition-colors"
|
|
>
|
|
<code className="bg-black/20 rounded px-1 font-mono">{t('formatMono')}</code> <span className="text-xs text-zinc-500 ml-auto">{t('formatMonoHint')}</span>
|
|
</button>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Schedule toast notification */}
|
|
<AnimatePresence>
|
|
{scheduleToast && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
onAnimationComplete={() => {
|
|
setTimeout(() => setScheduleToast(null), 3500);
|
|
}}
|
|
className="absolute bottom-[calc(100%+8px)] left-1/2 -translate-x-1/2 z-[9999] px-4 py-2.5 rounded-xl bg-surface shadow-2xl border border-border flex items-center gap-2 whitespace-nowrap"
|
|
>
|
|
<Check size={16} className="text-emerald-400 flex-shrink-0" />
|
|
<span className="text-sm text-zinc-200">{t('messageScheduled')}</span>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* =================== Schedule Mini Calendar =================== */
|
|
function ScheduleCalendar({
|
|
calDate, setCalDate, calMonth, setCalMonth, calYear, setCalYear,
|
|
hour, setHour, minute, setMinute, onSend, t,
|
|
}: {
|
|
calDate: string;
|
|
setCalDate: (v: string) => void;
|
|
calMonth: number;
|
|
setCalMonth: (v: number) => void;
|
|
calYear: number;
|
|
setCalYear: (v: number) => void;
|
|
hour: string;
|
|
setHour: (v: string) => void;
|
|
minute: string;
|
|
setMinute: (v: string) => void;
|
|
onSend: (iso: string) => void;
|
|
t: (k: any) => any;
|
|
}) {
|
|
const today = new Date();
|
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
|
|
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
|
const firstDayRaw = new Date(calYear, calMonth, 1).getDay();
|
|
const firstDay = firstDayRaw === 0 ? 6 : firstDayRaw - 1;
|
|
const cells: (number | null)[] = [];
|
|
for (let i = 0; i < firstDay; i++) cells.push(null);
|
|
for (let d2 = 1; d2 <= daysInMonth; d2++) cells.push(d2);
|
|
|
|
const weekDays = t('weekDays') as string[];
|
|
const months = t('months') as string[];
|
|
|
|
const prevMonth = () => {
|
|
if (calMonth === 0) { setCalMonth(11); setCalYear(calYear - 1); }
|
|
else setCalMonth(calMonth - 1);
|
|
};
|
|
const nextMonth = () => {
|
|
if (calMonth === 11) { setCalMonth(0); setCalYear(calYear + 1); }
|
|
else setCalMonth(calMonth + 1);
|
|
};
|
|
|
|
const selectDay = (day: number) => {
|
|
const m = String(calMonth + 1).padStart(2, '0');
|
|
const d = String(day).padStart(2, '0');
|
|
setCalDate(`${calYear}-${m}-${d}`);
|
|
};
|
|
|
|
const isSelected = (day: number) => {
|
|
const m = String(calMonth + 1).padStart(2, '0');
|
|
const d = String(day).padStart(2, '0');
|
|
return calDate === `${calYear}-${m}-${d}`;
|
|
};
|
|
|
|
const isToday = (day: number) => {
|
|
return today.getFullYear() === calYear && today.getMonth() === calMonth && today.getDate() === day;
|
|
};
|
|
|
|
const isPast = (day: number) => {
|
|
const m = String(calMonth + 1).padStart(2, '0');
|
|
const d = String(day).padStart(2, '0');
|
|
return `${calYear}-${m}-${d}` < todayStr;
|
|
};
|
|
|
|
const canSend = (() => {
|
|
if (!calDate) return false;
|
|
const dt = new Date(`${calDate}T${hour}:${minute}:00`);
|
|
return dt.getTime() > Date.now();
|
|
})();
|
|
|
|
const handleSend = () => {
|
|
if (!canSend) return;
|
|
const dt = new Date(`${calDate}T${hour}:${minute}:00`);
|
|
onSend(dt.toISOString());
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Mini calendar header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
|
|
<button onClick={prevMonth} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
|
|
<ChevronLeft size={16} />
|
|
</button>
|
|
<span className="text-xs font-medium text-zinc-300">{months[calMonth]} {calYear}</span>
|
|
<button onClick={nextMonth} className="p-1 rounded-lg hover:bg-white/10 text-zinc-400 hover:text-white transition-colors">
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Weekday headers */}
|
|
<div className="grid grid-cols-7 px-2 pt-1">
|
|
{weekDays.map((d) => (
|
|
<div key={d} className="text-center text-[10px] text-zinc-500 font-medium py-0.5">{d}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Days grid */}
|
|
<div className="grid grid-cols-7 px-2 pb-2">
|
|
{cells.map((day, i) => (
|
|
<div key={i} className="flex items-center justify-center">
|
|
{day ? (
|
|
<button
|
|
onClick={() => !isPast(day) && selectDay(day)}
|
|
disabled={isPast(day)}
|
|
className={`w-7 h-7 rounded-full text-xs flex items-center justify-center transition-all ${
|
|
isSelected(day)
|
|
? 'bg-accent text-white font-semibold shadow-lg shadow-accent/30'
|
|
: isPast(day)
|
|
? 'text-zinc-600 cursor-not-allowed'
|
|
: isToday(day)
|
|
? 'text-knot-400 font-semibold ring-1 ring-knot-500/50'
|
|
: 'text-zinc-300 hover:bg-white/10'
|
|
}`}
|
|
>
|
|
{day}
|
|
</button>
|
|
) : (
|
|
<span className="w-7 h-7" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Time picker */}
|
|
<div className="px-3 pb-2">
|
|
<label className="text-[11px] text-zinc-500 mb-1 block">{t('scheduleTime')}</label>
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={hour}
|
|
onChange={(e) => setHour(e.target.value)}
|
|
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-knot-500/50 appearance-none text-center"
|
|
>
|
|
{Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')).map((h) => (
|
|
<option key={h} value={h} className="bg-zinc-800">{h}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-zinc-400 font-bold">:</span>
|
|
<select
|
|
value={minute}
|
|
onChange={(e) => setMinute(e.target.value)}
|
|
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-knot-500/50 appearance-none text-center"
|
|
>
|
|
{Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')).map((m) => (
|
|
<option key={m} value={m} className="bg-zinc-800">{m}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Send button */}
|
|
<div className="px-3 pb-3">
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!canSend}
|
|
className="w-full py-2 rounded-xl bg-accent hover:bg-accent-hover disabled:bg-zinc-700 disabled:text-zinc-500 text-white text-sm font-medium transition-colors"
|
|
>
|
|
{t('scheduleSend')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|