feat: initial JaniChat messenger — PWA, WebSocket, admin panel
This commit is contained in:
199
frontend/src/components/MessageInput.tsx
Normal file
199
frontend/src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Paperclip, X, Smile } from 'lucide-react';
|
||||
import { wsClient } from '../api/ws';
|
||||
import api from '../api/client';
|
||||
import { Message } from '../types';
|
||||
import { useStore } from '../store';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
replyTo: Message | null;
|
||||
editMsg: Message | null;
|
||||
onCancelReply: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onEditDone: (msg: Message) => void;
|
||||
}
|
||||
|
||||
const EMOJIS = ['😀','😂','😍','🥰','😎','👍','❤️','🔥','✅','👋','🙏','💪','🎉','💯','😊','🤔','😅','🙌','💬','📌'];
|
||||
|
||||
export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply, onCancelEdit, onEditDone }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const typingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { addMessage, user } = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (editMsg) {
|
||||
setText(editMsg.content);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [editMsg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replyTo) textareaRef.current?.focus();
|
||||
}, [replyTo]);
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
setText(e.target.value);
|
||||
autoResize();
|
||||
|
||||
// Typing indicator
|
||||
wsClient.send('typing', { chatId, typing: true });
|
||||
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
||||
typingTimeout.current = setTimeout(() => {
|
||||
wsClient.send('typing', { chatId, typing: false });
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onCancelReply();
|
||||
onCancelEdit();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const content = text.trim();
|
||||
if (!content || sending) return;
|
||||
|
||||
setSending(true);
|
||||
setText('');
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
wsClient.send('typing', { chatId, typing: false });
|
||||
|
||||
if (editMsg) {
|
||||
wsClient.send('edit_message', { messageId: editMsg.id, content });
|
||||
onEditDone({ ...editMsg, content, isEdited: true });
|
||||
} else {
|
||||
wsClient.send('send_message', {
|
||||
chatId,
|
||||
content,
|
||||
type: 'text',
|
||||
replyToId: replyTo?.id || null,
|
||||
});
|
||||
onCancelReply();
|
||||
}
|
||||
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
|
||||
setUploading(true);
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const { data } = await api.post(`/api/messages/chat/${chatId}/upload`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
addMessage(data);
|
||||
} catch {
|
||||
alert('Ошибка загрузки файла');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function insertEmoji(emoji: string) {
|
||||
setText(t => t + emoji);
|
||||
setShowEmoji(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
|
||||
const isEdit = !!editMsg;
|
||||
const placeholder = isEdit ? 'Редактирование...' : replyTo ? 'Ответить...' : 'Сообщение...';
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 bg-white px-4 pb-safe pt-2">
|
||||
{/* Reply/Edit preview */}
|
||||
{(replyTo || editMsg) && (
|
||||
<div className="flex items-center gap-2 mb-2 pl-3 border-l-2 border-blue-400 bg-blue-50 rounded-r-lg py-1.5 pr-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-blue-600">
|
||||
{isEdit ? 'Редактирование' : `Ответ: ${replyTo?.sender?.displayName}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 truncate">
|
||||
{isEdit ? editMsg?.content : replyTo?.content}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={isEdit ? onCancelEdit : onCancelReply}
|
||||
className="p-0.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Emoji picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowEmoji(!showEmoji)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100"
|
||||
>
|
||||
<Smile className="w-5 h-5" />
|
||||
</button>
|
||||
{showEmoji && (
|
||||
<div className="absolute bottom-10 left-0 bg-white rounded-2xl shadow-xl p-3 grid grid-cols-5 gap-1 z-10 border border-gray-100">
|
||||
{EMOJIS.map(e => (
|
||||
<button key={e} onClick={() => insertEmoji(e)} className="text-xl hover:bg-gray-100 rounded-lg p-1">
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-gray-100 rounded-2xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 max-h-[120px] leading-relaxed"
|
||||
/>
|
||||
|
||||
{/* File upload */}
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100 disabled:opacity-50"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</button>
|
||||
<input ref={fileRef} type="file" className="hidden" onChange={handleFile} />
|
||||
|
||||
{/* Send */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim() || sending}
|
||||
className="p-2.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white rounded-xl transition-colors"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user