feat: initial JaniChat messenger — PWA, WebSocket, admin panel

This commit is contained in:
Ai
2026-05-22 11:06:43 +03:00
commit 1cabe9d04f
46 changed files with 3570 additions and 0 deletions

95
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from './pages/Login';
import MainLayout from './pages/MainLayout';
import { useStore } from './store';
import { wsClient } from './api/ws';
import api from './api/client';
function AuthGuard({ children }: { children: React.ReactNode }) {
const user = useStore(s => s.user);
return user ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
const { setUser, setChats, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead } = useStore();
useEffect(() => {
// Restore session
const token = localStorage.getItem('jc_token');
const userStr = localStorage.getItem('jc_user');
if (token && userStr) {
try {
setUser(JSON.parse(userStr));
connectWS(token);
loadChats();
} catch {
localStorage.removeItem('jc_token');
localStorage.removeItem('jc_user');
}
}
}, []);
async function loadChats() {
try {
const { data } = await api.get('/api/chats');
setChats(data);
} catch {}
}
function connectWS(token: string) {
wsClient.disconnect();
wsClient.connect(token);
wsClient.on('connected', () => setConnected(true));
wsClient.on('disconnected', () => setConnected(false));
wsClient.on('new_message', (msg) => {
addMessage(msg);
});
wsClient.on('message_edited', (msg) => {
updateMessage(msg);
});
wsClient.on('message_deleted', ({ messageId, chatId }) => {
removeMessage(chatId, messageId);
});
wsClient.on('user_online', ({ userId, online }) => {
setOnline(userId, online);
});
wsClient.on('typing', ({ chatId, userId, typing }) => {
setTyping(chatId, userId, typing);
});
wsClient.on('messages_read', ({ chatId }) => {
markRead(chatId);
});
// Handle SW notification click
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'open_chat') {
// Handled by MainLayout
}
});
}
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage onLogin={(token, user) => {
localStorage.setItem('jc_token', token);
localStorage.setItem('jc_user', JSON.stringify(user));
setUser(user);
connectWS(token);
loadChats();
}} />} />
<Route path="/*" element={<AuthGuard><MainLayout /></AuthGuard>} />
</Routes>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,25 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('jc_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('jc_token');
localStorage.removeItem('jc_user');
window.location.href = '/login';
}
return Promise.reject(err);
}
);
export default api;

70
frontend/src/api/ws.ts Normal file
View File

@@ -0,0 +1,70 @@
type Handler = (payload: any) => void;
class WsClient {
private ws: WebSocket | null = null;
private handlers = new Map<string, Handler[]>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private token: string | null = null;
connect(token: string) {
this.token = token;
const wsBase = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
this.ws = new WebSocket(`${wsBase}/ws?token=${token}`);
this.ws.onopen = () => {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.emit('connected', null);
};
this.ws.onmessage = (event) => {
try {
const { type, payload } = JSON.parse(event.data);
const hs = this.handlers.get(type) || [];
hs.forEach(h => h(payload));
} catch {}
};
this.ws.onclose = () => {
this.emit('disconnected', null);
this.reconnectTimer = setTimeout(() => {
if (this.token) this.connect(this.token);
}, 3000);
};
this.ws.onerror = () => {
this.ws?.close();
};
}
on(type: string, handler: Handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type)!.push(handler);
return () => {
const hs = this.handlers.get(type) || [];
this.handlers.set(type, hs.filter(h => h !== handler));
};
}
private emit(type: string, payload: any) {
const hs = this.handlers.get(type) || [];
hs.forEach(h => h(payload));
}
send(type: string, payload: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload }));
}
}
disconnect() {
this.token = null;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.ws = null;
}
}
export const wsClient = new WsClient();

View File

@@ -0,0 +1,34 @@
interface Props {
name: string;
color: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
online?: boolean;
className?: string;
}
const sizes = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-xl',
};
export default function Avatar({ name, color, size = 'md', online, className = '' }: Props) {
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div className={`relative flex-shrink-0 ${className}`}>
<div
className={`${sizes[size]} rounded-full flex items-center justify-center font-semibold text-white select-none`}
style={{ backgroundColor: color }}
>
{initials}
</div>
{online !== undefined && (
<div className={`absolute bottom-0 right-0 rounded-full border-2 border-white ${
online ? 'bg-green-400' : 'bg-gray-300'
} ${size === 'sm' ? 'w-2.5 h-2.5' : 'w-3 h-3'}`} />
)}
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { ArrowLeft, MoreVertical, Users, Info, LogOut, Trash2, Settings } from 'lucide-react';
import { useState } from 'react';
import { Chat } from '../types';
import Avatar from './Avatar';
import { useStore } from '../store';
import { wsClient } from '../api/ws';
import api from '../api/client';
interface Props {
chat: Chat;
onBack: () => void;
onRefresh: () => void;
onShowInfo: () => void;
}
export default function ChatHeader({ chat, onBack, onRefresh, onShowInfo }: Props) {
const { user, typingUsers, onlineUsers, setActiveChat, setChats, chats } = useStore();
const [menuOpen, setMenuOpen] = useState(false);
const typing = typingUsers[chat.id] || [];
const isPrivate = chat.type === 'private';
const isOnline = isPrivate && chat.privateUserId && onlineUsers.has(chat.privateUserId);
let subtitle = '';
if (typing.length > 0) {
subtitle = 'печатает...';
} else if (isPrivate) {
subtitle = isOnline ? 'в сети' : 'не в сети';
} else {
subtitle = `${chat.memberCount} участников`;
}
const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || user?.isAdmin;
async function handleLeave() {
if (!confirm('Выйти из чата?')) return;
await api.delete(`/api/chats/${chat.id}/leave`);
setActiveChat(null);
setChats(chats.filter(c => c.id !== chat.id));
setMenuOpen(false);
}
async function handleDelete() {
if (!confirm('Удалить чат? Это действие нельзя отменить.')) return;
await api.delete(`/api/chats/${chat.id}`);
setActiveChat(null);
setChats(chats.filter(c => c.id !== chat.id));
setMenuOpen(false);
}
const typeLabel = chat.type === 'channel' ? 'Канал' : chat.type === 'group' ? 'Группа' : '';
return (
<div className="bg-white border-b border-gray-100 px-4 py-3 flex items-center gap-3 shadow-sm relative z-10">
{/* Back button (mobile) */}
<button
onClick={onBack}
className="md:hidden p-1 -ml-1 rounded-lg hover:bg-gray-100 text-gray-600"
>
<ArrowLeft className="w-5 h-5" />
</button>
{/* Avatar */}
<div className="cursor-pointer" onClick={onShowInfo}>
<Avatar
name={chat.title}
color={chat.avatarColor}
online={isPrivate ? isOnline : undefined}
/>
</div>
{/* Title & status */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onShowInfo}>
<div className="font-semibold text-gray-900 text-sm truncate">
{typeLabel && <span className="text-gray-400 font-normal text-xs mr-1">{typeLabel}</span>}
{chat.title}
</div>
<div className={`text-xs truncate ${typing.length > 0 ? 'text-blue-500' : isOnline ? 'text-green-500' : 'text-gray-400'}`}>
{subtitle}
</div>
</div>
{/* Menu */}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600"
>
<MoreVertical className="w-5 h-5" />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-10 bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-48 z-20">
<button
onClick={() => { onShowInfo(); setMenuOpen(false); }}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Info className="w-4 h-4" /> Информация
</button>
{chat.type !== 'private' && isOwnerOrAdmin && (
<button
onClick={() => { onShowInfo(); setMenuOpen(false); }}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Settings className="w-4 h-4" /> Управление
</button>
)}
{chat.type !== 'private' && (
<button
onClick={handleLeave}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
<LogOut className="w-4 h-4" /> Выйти
</button>
)}
{isOwnerOrAdmin && (
<button
onClick={handleDelete}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" /> Удалить чат
</button>
)}
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,186 @@
import { X, UserPlus, Crown, Shield, User, Trash2, Ban } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Chat, ChatMember } from '../types';
import Avatar from './Avatar';
import { useStore } from '../store';
import api from '../api/client';
interface Props {
chat: Chat;
onClose: () => void;
onRefresh: () => void;
}
export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) {
const { user: me, onlineUsers } = useStore();
const [members, setMembers] = useState<ChatMember[]>(chat.members || []);
const [allUsers, setAllUsers] = useState<any[]>([]);
const [addMode, setAddMode] = useState(false);
const [search, setSearch] = useState('');
const [title, setTitle] = useState(chat.title || '');
const [editing, setEditing] = useState(false);
const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || me?.isAdmin;
useEffect(() => {
if (addMode) {
api.get('/api/users').then(r => setAllUsers(r.data));
}
}, [addMode]);
async function saveTitle() {
await api.put(`/api/chats/${chat.id}`, { title });
setEditing(false);
onRefresh();
}
async function addMember(userId: string) {
await api.post(`/api/chats/${chat.id}/members`, { memberId: userId });
const { data } = await api.get(`/api/chats/${chat.id}`);
setMembers(data.members);
setAddMode(false);
}
async function removeMember(userId: string) {
if (!confirm('Удалить участника?')) return;
await api.delete(`/api/chats/${chat.id}/members/${userId}`);
setMembers(m => m.filter(x => x.id !== userId));
}
async function toggleRole(m: ChatMember) {
const newRole = m.role === 'admin' ? 'member' : 'admin';
await api.put(`/api/chats/${chat.id}/members/${m.id}`, { role: newRole });
setMembers(mems => mems.map(x => x.id === m.id ? { ...x, role: newRole } : x));
}
async function toggleSend(m: ChatMember) {
await api.put(`/api/chats/${chat.id}/members/${m.id}`, { canSendMessages: !m.canSendMessages });
setMembers(mems => mems.map(x => x.id === m.id ? { ...x, canSendMessages: !x.canSendMessages } : x));
}
const memberIds = new Set(members.map(m => m.id));
const filtered = allUsers.filter(u =>
!memberIds.has(u.id) &&
(u.displayName.toLowerCase().includes(search.toLowerCase()) ||
u.username.toLowerCase().includes(search.toLowerCase()))
);
const roleIcon = (role: string) =>
role === 'owner' ? <Crown className="w-3 h-3 text-yellow-500" />
: role === 'admin' ? <Shield className="w-3 h-3 text-blue-500" />
: <User className="w-3 h-3 text-gray-400" />;
return (
<div className="w-72 bg-white border-l border-gray-100 flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="font-semibold text-gray-900 text-sm">
{chat.type === 'channel' ? 'Канал' : chat.type === 'group' ? 'Группа' : 'Контакт'}
</h3>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Avatar & Title */}
<div className="flex flex-col items-center py-6 px-4">
<Avatar name={chat.title} color={chat.avatarColor} size="xl" />
<div className="mt-3 w-full">
{editing && chat.type !== 'private' ? (
<div className="flex gap-2">
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="flex-1 px-2 py-1 border rounded-lg text-sm"
/>
<button onClick={saveTitle} className="px-3 py-1 bg-blue-500 text-white text-sm rounded-lg">OK</button>
<button onClick={() => setEditing(false)} className="px-3 py-1 bg-gray-100 text-sm rounded-lg"></button>
</div>
) : (
<div className="text-center">
<div
className="font-semibold text-gray-900 cursor-pointer hover:text-blue-500"
onClick={() => isOwnerOrAdmin && chat.type !== 'private' && setEditing(true)}
>
{chat.title}
</div>
{chat.description && <p className="text-xs text-gray-500 mt-1">{chat.description}</p>}
</div>
)}
</div>
</div>
{/* Members */}
{chat.type !== 'private' && (
<div className="px-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Участники ({members.length})
</span>
{isOwnerOrAdmin && (
<button onClick={() => setAddMode(!addMode)} className="p-1 rounded hover:bg-gray-100 text-blue-500">
<UserPlus className="w-4 h-4" />
</button>
)}
</div>
{addMode && (
<div className="mb-3">
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Поиск пользователей..."
className="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2"
/>
<div className="max-h-40 overflow-y-auto space-y-1">
{filtered.map(u => (
<button
key={u.id}
onClick={() => addMember(u.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-blue-50 text-sm text-left"
>
<Avatar name={u.displayName} color={u.avatarColor} size="sm" />
<span>{u.displayName}</span>
</button>
))}
{filtered.length === 0 && <p className="text-xs text-gray-400 text-center py-2">Никого не найдено</p>}
</div>
</div>
)}
<div className="space-y-1 pb-4">
{members.map(m => (
<div key={m.id} className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-gray-50 group">
<Avatar name={m.displayName} color={m.avatarColor} size="sm" online={onlineUsers.has(m.id)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 text-sm font-medium text-gray-900">
{m.displayName}
{roleIcon(m.role)}
{!m.canSendMessages && <Ban className="w-3 h-3 text-red-400" />}
</div>
<div className="text-xs text-gray-400">@{m.username}</div>
</div>
{isOwnerOrAdmin && m.id !== me?.id && m.role !== 'owner' && (
<div className="hidden group-hover:flex gap-1">
<button onClick={() => toggleRole(m)} title="Изменить роль" className="p-1 hover:bg-gray-200 rounded">
<Shield className="w-3 h-3 text-blue-400" />
</button>
{chat.type === 'channel' && (
<button onClick={() => toggleSend(m)} title="Запрет сообщений" className="p-1 hover:bg-gray-200 rounded">
<Ban className="w-3 h-3 text-orange-400" />
</button>
)}
<button onClick={() => removeMember(m.id)} title="Удалить" className="p-1 hover:bg-gray-200 rounded">
<Trash2 className="w-3 h-3 text-red-400" />
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
import Avatar from './Avatar';
import { Chat } from '../types';
interface Props {
chat: Chat;
active: boolean;
online?: boolean;
onClick: () => void;
}
export default function ChatListItem({ chat, active, online, onClick }: Props) {
const time = chat.lastMessageAt
? formatDistanceToNow(new Date(chat.lastMessageAt), { addSuffix: false, locale: ru })
: '';
const icon = chat.type === 'channel' ? '📢' : chat.type === 'group' ? '👥' : null;
return (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors ${
active ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<Avatar
name={chat.title}
color={chat.avatarColor}
online={chat.type === 'private' ? online : undefined}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 text-sm truncate">
{icon && <span className="mr-1 text-xs">{icon}</span>}
{chat.title}
</span>
{time && <span className="text-xs text-gray-400 flex-shrink-0 ml-1">{time}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-gray-500 truncate">
{chat.lastMessage || (chat.type === 'channel' ? 'Канал' : 'Нет сообщений')}
</p>
{chat.unreadCount > 0 && (
<span className="bg-blue-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 flex-shrink-0 ml-1">
{chat.unreadCount > 99 ? '99+' : chat.unreadCount}
</span>
)}
</div>
</div>
</button>
);
}

View 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>
);
}

View File

@@ -0,0 +1,144 @@
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Check, CheckCheck, Pencil, Trash2, Reply, Download } from 'lucide-react';
import { useState } from 'react';
import { Message } from '../types';
import Avatar from './Avatar';
interface Props {
message: Message;
isMine: boolean;
showAvatar: boolean;
isGroup: boolean;
onReply: (msg: Message) => void;
onDelete: (id: string) => void;
onEdit: (msg: Message) => void;
}
export default function MessageItem({ message, isMine, showAvatar, isGroup, onReply, onDelete, onEdit }: Props) {
const [showMenu, setShowMenu] = useState(false);
if (message.isDeleted) {
return (
<div className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
{showAvatar && !isMine ? <div className="w-8" /> : null}
<div className={`px-3 py-2 rounded-2xl text-sm italic text-gray-400 bg-gray-100 max-w-xs`}>
Сообщение удалено
</div>
</div>
);
}
const time = format(new Date(message.createdAt), 'HH:mm', { locale: ru });
const imageAttachments = message.attachments.filter(a => a.mimeType.startsWith('image/'));
const fileAttachments = message.attachments.filter(a => !a.mimeType.startsWith('image/'));
return (
<div
className={`flex items-end gap-2 mb-1 group ${isMine ? 'flex-row-reverse' : ''}`}
onMouseLeave={() => setShowMenu(false)}
>
{/* Avatar */}
{!isMine && isGroup ? (
showAvatar && message.sender ? (
<Avatar name={message.sender.displayName} color={message.sender.avatarColor} size="sm" />
) : <div className="w-8 flex-shrink-0" />
) : null}
{/* Bubble */}
<div className={`relative max-w-[70%] lg:max-w-[60%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
{/* Sender name in group */}
{!isMine && isGroup && showAvatar && message.sender && (
<span className="text-xs font-medium mb-0.5 px-1" style={{ color: message.sender.avatarColor }}>
{message.sender.displayName}
</span>
)}
{/* Reply preview */}
{message.replyTo && (
<div className={`mb-1 px-3 py-1.5 rounded-xl text-xs border-l-2 border-blue-400 bg-blue-50 max-w-full`}>
<div className="font-medium text-blue-600">{message.replyTo.senderName}</div>
<div className="text-gray-600 truncate">{message.replyTo.content}</div>
</div>
)}
{/* Images */}
{imageAttachments.length > 0 && (
<div className="mb-1 rounded-xl overflow-hidden">
{imageAttachments.map(a => (
<a key={a.id || a.filename} href={a.url} target="_blank" rel="noopener noreferrer">
<img src={a.url} alt={a.originalName} className="max-w-full max-h-64 object-cover rounded-xl" />
</a>
))}
</div>
)}
{/* File attachments */}
{fileAttachments.map(a => (
<a
key={a.id || a.filename}
href={a.url}
download={a.originalName}
className={`flex items-center gap-2 mb-1 px-3 py-2 rounded-xl text-sm ${isMine ? 'bg-blue-500 text-white' : 'bg-white border border-gray-200 text-gray-800'}`}
>
<Download className="w-4 h-4 flex-shrink-0" />
<span className="truncate max-w-[200px]">{a.originalName}</span>
<span className="text-xs opacity-70">{(a.size / 1024).toFixed(0)}кб</span>
</a>
))}
{/* Text bubble */}
{(message.content && message.type !== 'system') && (
<div
className={`msg-enter px-3 py-2 rounded-2xl text-sm leading-relaxed ${
isMine
? 'bg-blue-500 text-white rounded-br-sm'
: 'bg-white text-gray-900 shadow-sm rounded-bl-sm'
}`}
>
<span className="break-words whitespace-pre-wrap">{message.content}</span>
<span className={`text-xs ml-2 float-right mt-1 flex items-center gap-0.5 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>
{message.isEdited && <span className="mr-1">ред.</span>}
{time}
{isMine && <CheckCheck className="w-3 h-3 ml-0.5" />}
</span>
</div>
)}
{message.type === 'system' && (
<div className="text-xs text-center text-gray-400 italic py-1">{message.content}</div>
)}
</div>
{/* Actions */}
<div className={`opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 ${isMine ? 'flex-row-reverse' : ''}`}>
<button
onClick={() => onReply(message)}
className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors"
title="Ответить"
>
<Reply className="w-3.5 h-3.5" />
</button>
{isMine && message.type === 'text' && (
<button
onClick={() => onEdit(message)}
className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors"
title="Редактировать"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{isMine && (
<button
onClick={() => onDelete(message.id)}
className="p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors"
title="Удалить"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { format, isToday, isYesterday, isSameDay } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Message } from '../types';
import MessageItem from './MessageItem';
import { useStore } from '../store';
import api from '../api/client';
import { wsClient } from '../api/ws';
import { ChevronDown } from 'lucide-react';
interface Props {
chatId: string;
isGroup: boolean;
canSend: boolean;
}
function DateSeparator({ date }: { date: Date }) {
const label = isToday(date) ? 'Сегодня'
: isYesterday(date) ? 'Вчера'
: format(date, 'd MMMM yyyy', { locale: ru });
return (
<div className="flex items-center justify-center my-3">
<span className="bg-white text-gray-500 text-xs px-3 py-1 rounded-full shadow-sm">{label}</span>
</div>
);
}
export default function MessageList({ chatId, isGroup, canSend }: Props) {
const { messages, setMessages, prependMessages, user, removeMessage, updateMessage } = useStore();
const msgs = messages[chatId] || [];
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [replyTo, setReplyTo] = useState<Message | null>(null);
const [editMsg, setEditMsg] = useState<Message | null>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const isAtBottom = useRef(true);
useEffect(() => {
loadMessages();
}, [chatId]);
useEffect(() => {
if (isAtBottom.current) scrollToBottom();
}, [msgs.length]);
async function loadMessages() {
setLoading(true);
setHasMore(true);
try {
const { data } = await api.get(`/api/messages/chat/${chatId}`);
setMessages(chatId, data);
} finally {
setLoading(false);
setTimeout(scrollToBottom, 50);
}
}
async function loadMore() {
if (loading || !hasMore || msgs.length === 0) return;
const firstId = msgs[0]?.id;
if (!firstId) return;
setLoading(true);
const prevHeight = containerRef.current?.scrollHeight || 0;
try {
const { data } = await api.get(`/api/messages/chat/${chatId}?before=${firstId}`);
if (data.length === 0) { setHasMore(false); return; }
prependMessages(chatId, data);
// Maintain scroll position
setTimeout(() => {
const el = containerRef.current;
if (el) el.scrollTop = el.scrollHeight - prevHeight;
}, 10);
} finally {
setLoading(false);
}
}
function scrollToBottom() {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
setShowScrollBtn(false);
}
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const el = e.currentTarget;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
isAtBottom.current = atBottom;
setShowScrollBtn(!atBottom);
if (el.scrollTop < 100) loadMore();
}
function handleDelete(id: string) {
wsClient.send('delete_message', { messageId: id });
}
function handleEdit(msg: Message) {
setEditMsg(msg);
setReplyTo(null);
}
function handleReply(msg: Message) {
setReplyTo(msg);
setEditMsg(null);
}
// Group messages by date and determine showAvatar
const rendered: JSX.Element[] = [];
let lastDate: Date | null = null;
msgs.forEach((msg, i) => {
const date = new Date(msg.createdAt);
if (!lastDate || !isSameDay(date, lastDate)) {
rendered.push(<DateSeparator key={`sep-${msg.id}`} date={date} />);
lastDate = date;
}
const next = msgs[i + 1];
const showAvatar = !next || next.sender?.id !== msg.sender?.id ||
new Date(next.createdAt).getTime() - date.getTime() > 60000;
rendered.push(
<MessageItem
key={msg.id}
message={msg}
isMine={msg.sender?.id === user?.id}
showAvatar={showAvatar}
isGroup={isGroup}
onReply={handleReply}
onDelete={handleDelete}
onEdit={handleEdit}
/>
);
});
return (
<div className="flex flex-col h-full relative">
{/* Messages */}
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5"
>
{loading && msgs.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8">Загрузка...</div>
)}
{!loading && msgs.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8">Нет сообщений. Начните общение!</div>
)}
{rendered}
<div ref={bottomRef} />
</div>
{/* Scroll to bottom button */}
{showScrollBtn && (
<button
onClick={scrollToBottom}
className="absolute bottom-20 right-4 bg-white shadow-lg rounded-full p-2 hover:bg-gray-50 transition-colors"
>
<ChevronDown className="w-5 h-5 text-gray-600" />
</button>
)}
{/* Input area */}
{canSend && (
<MessageInput
chatId={chatId}
replyTo={replyTo}
editMsg={editMsg}
onCancelReply={() => setReplyTo(null)}
onCancelEdit={() => setEditMsg(null)}
onEditDone={(msg) => { updateMessage(msg); setEditMsg(null); }}
/>
)}
</div>
);
}
// MessageInput is imported inside
import MessageInput from './MessageInput';

View File

@@ -0,0 +1,227 @@
import { useState, useEffect } from 'react';
import { X, Search, MessageCircle, Users, Radio } from 'lucide-react';
import api from '../api/client';
import { useStore } from '../store';
import Avatar from './Avatar';
interface Props {
onClose: () => void;
onOpen: (chatId: string) => void;
}
type Step = 'type' | 'user' | 'group';
export default function NewChatModal({ onClose, onOpen }: Props) {
const [step, setStep] = useState<Step>('type');
const [chatType, setChatType] = useState<'private' | 'group' | 'channel'>('private');
const [users, setUsers] = useState<any[]>([]);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [groupTitle, setGroupTitle] = useState('');
const [loading, setLoading] = useState(false);
const { setChats, chats } = useStore();
useEffect(() => {
api.get('/api/users').then(r => setUsers(r.data));
}, []);
const filtered = users.filter(u =>
u.displayName.toLowerCase().includes(search.toLowerCase()) ||
u.username.toLowerCase().includes(search.toLowerCase())
);
function toggleSelect(id: string) {
setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
}
async function createPrivate(userId: string) {
setLoading(true);
try {
const { data } = await api.post(`/api/chats/private/${userId}`);
const { data: chat } = await api.get(`/api/chats/${data.id}`);
// Refresh chats
const { data: allChats } = await api.get('/api/chats');
setChats(allChats);
onOpen(data.id);
onClose();
} finally {
setLoading(false);
}
}
async function createGroup() {
if (!groupTitle.trim()) return;
setLoading(true);
try {
const { data } = await api.post('/api/chats', {
type: chatType,
title: groupTitle,
memberIds: selected,
});
const { data: allChats } = await api.get('/api/chats');
setChats(allChats);
onOpen(data.id);
onClose();
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
<div className="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">
{step === 'type' ? 'Новый чат' : step === 'user' ? 'Выбор пользователя' : 'Создание ' + (chatType === 'channel' ? 'канала' : 'группы')}
</h2>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-5 h-5" />
</button>
</div>
{/* Step 1: Type selection */}
{step === 'type' && (
<div className="p-5 space-y-3">
<button
onClick={() => { setChatType('private'); setStep('user'); }}
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-blue-50 border border-gray-100 transition-colors"
>
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<MessageCircle className="w-5 h-5 text-blue-600" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900">Личный чат</div>
<div className="text-sm text-gray-500">Переписка с одним пользователем</div>
</div>
</button>
<button
onClick={() => { setChatType('group'); setStep('group'); }}
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-purple-50 border border-gray-100 transition-colors"
>
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
<Users className="w-5 h-5 text-purple-600" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900">Группа</div>
<div className="text-sm text-gray-500">Общение нескольких участников</div>
</div>
</button>
<button
onClick={() => { setChatType('channel'); setStep('group'); }}
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-orange-50 border border-gray-100 transition-colors"
>
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
<Radio className="w-5 h-5 text-orange-600" />
</div>
<div className="text-left">
<div className="font-medium text-gray-900">Канал</div>
<div className="text-sm text-gray-500">Публикации от администраторов</div>
</div>
</button>
</div>
)}
{/* Step 2: Select user (private) */}
{step === 'user' && (
<>
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
<Search className="w-4 h-4 text-gray-400" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск..." className="flex-1 bg-transparent text-sm outline-none"
autoFocus
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{filtered.map(u => (
<button
key={u.id}
onClick={() => createPrivate(u.id)}
disabled={loading}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 text-left"
>
<Avatar name={u.displayName} color={u.avatarColor} online={u.online} />
<div>
<div className="font-medium text-sm text-gray-900">{u.displayName}</div>
<div className="text-xs text-gray-400">@{u.username}</div>
</div>
</button>
))}
</div>
</>
)}
{/* Step 3: Create group/channel */}
{step === 'group' && (
<>
<div className="px-4 py-3 border-b border-gray-100 space-y-3">
<input
value={groupTitle}
onChange={e => setGroupTitle(e.target.value)}
placeholder={chatType === 'channel' ? 'Название канала' : 'Название группы'}
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
autoFocus
/>
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
<Search className="w-4 h-4 text-gray-400" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Добавить участников..." className="flex-1 bg-transparent text-sm outline-none"
/>
</div>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map(id => {
const u = users.find(x => x.id === id);
return u ? (
<span key={id} onClick={() => toggleSelect(id)}
className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full cursor-pointer">
{u.displayName} ×
</span>
) : null;
})}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-1">
{filtered.map(u => (
<button
key={u.id}
onClick={() => toggleSelect(u.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors ${
selected.includes(u.id) ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<div className="relative">
<Avatar name={u.displayName} color={u.avatarColor} />
{selected.includes(u.id) && (
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-[10px]"></span>
</div>
)}
</div>
<div>
<div className="font-medium text-sm text-gray-900">{u.displayName}</div>
<div className="text-xs text-gray-400">@{u.username}</div>
</div>
</button>
))}
</div>
<div className="p-4 border-t border-gray-100">
<button
onClick={createGroup}
disabled={!groupTitle.trim() || loading}
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white py-3 rounded-xl font-medium text-sm transition-colors"
>
{loading ? 'Создание...' : `Создать ${chatType === 'channel' ? 'канал' : 'группу'}`}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { X, Plus, Pencil, Trash2, Users, MessageSquare, BarChart3, Check, RefreshCw } from 'lucide-react';
import api from '../../api/client';
import Avatar from '../Avatar';
interface Props { onClose: () => void; }
interface UserRow {
id: string; username: string; displayName: string; avatarColor: string;
isAdmin: boolean; isActive: boolean; lastSeen: string; createdAt: string;
}
export default function AdminPanel({ onClose }: Props) {
const [tab, setTab] = useState<'users' | 'stats'>('users');
const [users, setUsers] = useState<UserRow[]>([]);
const [stats, setStats] = useState<any>(null);
const [showCreate, setShowCreate] = useState(false);
const [editUser, setEditUser] = useState<UserRow | null>(null);
useEffect(() => { loadUsers(); loadStats(); }, []);
async function loadUsers() {
const { data } = await api.get('/api/admin/users');
setUsers(data);
}
async function loadStats() {
const { data } = await api.get('/api/admin/stats');
setStats(data);
}
async function deleteUser(id: string) {
if (!confirm('Удалить пользователя?')) return;
await api.delete(`/api/admin/users/${id}`);
loadUsers();
}
async function toggleActive(u: UserRow) {
await api.put(`/api/admin/users/${u.id}`, { isActive: !u.isActive });
loadUsers();
}
return (
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
<div className="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-900">Панель администратора</h2>
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-100 px-4">
{[['users','Пользователи'],['stats','Статистика']].map(([k,v]) => (
<button key={k} onClick={() => setTab(k as any)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
tab === k ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}>{v}</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4">
{tab === 'users' && (
<>
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500">{users.length} пользователей</span>
<button
onClick={() => { setShowCreate(true); setEditUser(null); }}
className="flex items-center gap-2 bg-blue-500 text-white px-4 py-2 rounded-xl text-sm hover:bg-blue-600 transition-colors"
>
<Plus className="w-4 h-4" /> Добавить
</button>
</div>
{(showCreate || editUser) && (
<UserForm
user={editUser}
onSave={() => { setShowCreate(false); setEditUser(null); loadUsers(); }}
onCancel={() => { setShowCreate(false); setEditUser(null); }}
/>
)}
<div className="space-y-2">
{users.map(u => (
<div key={u.id} className={`flex items-center gap-3 p-3 rounded-xl border ${u.isActive ? 'border-gray-100 bg-white' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
<Avatar name={u.displayName} color={u.avatarColor} size="sm" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-gray-900">{u.displayName}</span>
{u.isAdmin && <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded-full">admin</span>}
{!u.isActive && <span className="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full">заблокирован</span>}
</div>
<div className="text-xs text-gray-400">@{u.username}</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => toggleActive(u)} title={u.isActive ? 'Заблокировать' : 'Активировать'}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
{u.isActive ? <Check className="w-4 h-4 text-green-500" /> : <RefreshCw className="w-4 h-4" />}
</button>
<button onClick={() => { setEditUser(u); setShowCreate(false); }}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => deleteUser(u.id)}
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</>
)}
{tab === 'stats' && stats && (
<div className="grid grid-cols-3 gap-4">
{[
{ label: 'Пользователи', value: stats.users, icon: Users, color: 'blue' },
{ label: 'Чаты', value: stats.chats, icon: MessageSquare, color: 'green' },
{ label: 'Сообщения', value: stats.messages, icon: BarChart3, color: 'purple' },
].map(({ label, value, icon: Icon, color }) => (
<div key={label} className={`bg-${color}-50 rounded-2xl p-5 text-center`}>
<Icon className={`w-8 h-8 text-${color}-500 mx-auto mb-2`} />
<div className={`text-3xl font-bold text-${color}-700`}>{value}</div>
<div className={`text-sm text-${color}-600 mt-1`}>{label}</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
function UserForm({ user, onSave, onCancel }: { user: UserRow | null; onSave: () => void; onCancel: () => void }) {
const [form, setForm] = useState({
username: user?.username || '',
displayName: user?.displayName || '',
password: '',
isAdmin: user?.isAdmin || false,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
if (user) {
await api.put(`/api/admin/users/${user.id}`, {
displayName: form.displayName,
isAdmin: form.isAdmin,
password: form.password || undefined,
});
} else {
await api.post('/api/admin/users', form);
}
onSave();
} catch (err: any) {
setError(err.response?.data?.error || 'Ошибка');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="bg-blue-50 rounded-xl p-4 mb-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
{!user && (
<input value={form.username} onChange={e => setForm({...form, username: e.target.value})}
placeholder="Логин" required className="px-3 py-2 border border-gray-200 rounded-lg text-sm" />
)}
<input value={form.displayName} onChange={e => setForm({...form, displayName: e.target.value})}
placeholder="Имя" required className="px-3 py-2 border border-gray-200 rounded-lg text-sm" />
<input value={form.password} onChange={e => setForm({...form, password: e.target.value})}
type="password" placeholder={user ? 'Новый пароль (необязательно)' : 'Пароль'}
required={!user} className="px-3 py-2 border border-gray-200 rounded-lg text-sm" />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="checkbox" checked={form.isAdmin} onChange={e => setForm({...form, isAdmin: e.target.checked})} />
Администратор
</label>
{error && <p className="text-red-500 text-sm">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading}
className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 disabled:opacity-50">
{loading ? '...' : user ? 'Сохранить' : 'Создать'}
</button>
<button type="button" onClick={onCancel} className="px-4 py-2 bg-gray-100 text-sm rounded-lg hover:bg-gray-200">
Отмена
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,46 @@
import { useEffect } from 'react';
import api from '../api/client';
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
}
export function usePushNotifications(enabled: boolean) {
useEffect(() => {
if (!enabled) return;
if (!('Notification' in window) || !('serviceWorker' in navigator)) return;
async function subscribe() {
try {
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const { data } = await api.get('/api/push/vapid-public-key');
if (!data.key) return;
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.key),
});
}
const json = sub.toJSON();
await api.post('/api/push/subscribe', {
endpoint: json.endpoint,
keys: json.keys,
});
} catch (e) {
console.warn('Push subscription failed:', e);
}
}
subscribe();
}, [enabled]);
}

37
frontend/src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f0f2f5;
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-enter { animation: fadeIn 0.15s ease-out; }
/* Safe areas for mobile */
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
.pt-safe { padding-top: env(safe-area-inset-top); }

17
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
// Register service worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api/client';
import { User } from '../types';
interface Props {
onLogin: (token: string, user: User) => void;
}
export default function LoginPage({ onLogin }: Props) {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data } = await api.post('/api/auth/login', { username, password });
onLogin(data.token, data.user);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.error || 'Ошибка входа');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-8">
{/* Logo */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-blue-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-9 h-9 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900">JaniChat</h1>
<p className="text-gray-500 text-sm mt-1">Войдите в аккаунт</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Логин</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
placeholder="Введите логин"
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
placeholder="Введите пароль"
autoComplete="current-password"
required
/>
</div>
{error && (
<div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition"
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
<p className="text-center text-xs text-gray-400 mt-6">
Доступ только для зарегистрированных пользователей
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useState, useEffect } from 'react';
import { Search, Edit, LogOut, Settings, Shield, Wifi, WifiOff } from 'lucide-react';
import { useStore } from '../store';
import { useNavigate } from 'react-router-dom';
import { wsClient } from '../api/ws';
import api from '../api/client';
import Avatar from '../components/Avatar';
import ChatListItem from '../components/ChatListItem';
import MessageList from '../components/MessageList';
import ChatHeader from '../components/ChatHeader';
import ChatInfoPanel from '../components/ChatInfoPanel';
import NewChatModal from '../components/NewChatModal';
import AdminPanel from '../components/admin/AdminPanel';
import { usePushNotifications } from '../hooks/usePushNotifications';
import { Chat } from '../types';
export default function MainLayout() {
const { user, chats, activeChat, setActiveChat, connected, logout, onlineUsers, updateChat } = useStore();
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [showNew, setShowNew] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const [showInfo, setShowInfo] = useState(false);
const [mobileChatOpen, setMobileChatOpen] = useState(false);
const [fullChat, setFullChat] = useState<Chat | null>(null);
usePushNotifications(!!user);
// Handle URL param ?chat=xxx
useEffect(() => {
const params = new URLSearchParams(location.search);
const chatId = params.get('chat');
if (chatId) {
const chat = chats.find(c => c.id === chatId);
if (chat) openChat(chat);
}
}, [chats]);
// Handle SW messages
useEffect(() => {
if (!('serviceWorker' in navigator)) return;
const handler = (event: MessageEvent) => {
if (event.data?.type === 'open_chat') {
const chat = chats.find(c => c.id === event.data.chatId);
if (chat) openChat(chat);
}
};
navigator.serviceWorker.addEventListener('message', handler);
return () => navigator.serviceWorker.removeEventListener('message', handler);
}, [chats]);
async function openChat(chat: Chat) {
// Load full chat info
try {
const { data } = await api.get(`/api/chats/${chat.id}`);
const merged = { ...chat, ...data };
setActiveChat(merged);
setFullChat(merged);
updateChat({ id: chat.id, unreadCount: 0 });
wsClient.send('read_messages', { chatId: chat.id });
} catch {
setActiveChat(chat);
setFullChat(chat);
}
setMobileChatOpen(true);
setShowInfo(false);
}
function handleLogout() {
wsClient.disconnect();
logout();
navigate('/login');
}
const filtered = chats.filter(c =>
c.title?.toLowerCase().includes(search.toLowerCase())
);
const canSend = (() => {
if (!activeChat) return false;
if (activeChat.type === 'private') return true;
if (activeChat.type === 'group') return activeChat.canSendMessages !== false;
if (activeChat.type === 'channel') return activeChat.myRole === 'owner' || activeChat.myRole === 'admin' || !!user?.isAdmin;
return false;
})();
return (
<div className="flex h-full bg-gray-100">
{/* Sidebar */}
<div className={`w-full md:w-80 lg:w-96 bg-white flex flex-col border-r border-gray-100 ${mobileChatOpen ? 'hidden md:flex' : 'flex'}`}>
{/* Sidebar Header */}
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-3 mb-3">
<Avatar name={user?.displayName || ''} color={user?.avatarColor || '#3b82f6'} size="sm" />
<div className="flex-1 min-w-0">
<div className="font-semibold text-gray-900 text-sm truncate">{user?.displayName}</div>
<div className="flex items-center gap-1 text-xs text-gray-400">
{connected
? <><Wifi className="w-3 h-3 text-green-400" /> <span>Online</span></>
: <><WifiOff className="w-3 h-3 text-red-400" /> <span>Offline</span></>
}
</div>
</div>
<div className="flex items-center gap-1">
{user?.isAdmin && (
<button onClick={() => setShowAdmin(true)}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Панель администратора">
<Shield className="w-4 h-4" />
</button>
)}
<button onClick={() => setShowNew(true)}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Новый чат">
<Edit className="w-4 h-4" />
</button>
<button onClick={handleLogout}
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-red-500" title="Выйти">
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
{/* Search */}
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
<Search className="w-4 h-4 text-gray-400 flex-shrink-0" />
<input
value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск чатов..."
className="flex-1 bg-transparent text-sm outline-none text-gray-700 placeholder-gray-400"
/>
</div>
</div>
{/* Chat List */}
<div className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{filtered.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8">
{search ? 'Ничего не найдено' : 'Нет чатов. Создайте новый!'}
</div>
)}
{filtered.map(chat => (
<ChatListItem
key={chat.id}
chat={chat}
active={activeChat?.id === chat.id}
online={chat.type === 'private' && chat.privateUserId ? onlineUsers.has(chat.privateUserId) : undefined}
onClick={() => openChat(chat)}
/>
))}
</div>
</div>
{/* Main Chat Area */}
<div className={`flex-1 flex flex-col ${!mobileChatOpen ? 'hidden md:flex' : 'flex'}`}>
{activeChat && fullChat ? (
<>
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0">
<ChatHeader
chat={fullChat}
onBack={() => { setMobileChatOpen(false); setActiveChat(null); }}
onRefresh={() => openChat(activeChat)}
onShowInfo={() => setShowInfo(!showInfo)}
/>
<div className="flex-1 overflow-hidden bg-gray-50">
<MessageList
chatId={activeChat.id}
isGroup={activeChat.type !== 'private'}
canSend={canSend}
/>
</div>
</div>
{/* Info Panel */}
{showInfo && (
<ChatInfoPanel
chat={fullChat}
onClose={() => setShowInfo(false)}
onRefresh={() => openChat(activeChat)}
/>
)}
</div>
</>
) : (
<div className="hidden md:flex flex-1 items-center justify-center text-gray-400 flex-col gap-4">
<div className="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-12 h-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<div className="text-center">
<p className="text-lg font-medium text-gray-600">JaniChat</p>
<p className="text-sm text-gray-400 mt-1">Выберите чат или создайте новый</p>
</div>
</div>
)}
</div>
{/* Modals */}
{showNew && <NewChatModal onClose={() => setShowNew(false)} onOpen={(id) => {
const chat = chats.find(c => c.id === id);
if (chat) openChat(chat);
}} />}
{showAdmin && <AdminPanel onClose={() => setShowAdmin(false)} />}
</div>
);
}

121
frontend/src/store/index.ts Normal file
View File

@@ -0,0 +1,121 @@
import { create } from 'zustand';
import { Chat, Message, User } from '../types';
interface AppStore {
user: User | null;
chats: Chat[];
activeChat: Chat | null;
messages: Record<string, Message[]>;
onlineUsers: Set<string>;
typingUsers: Record<string, string[]>;
connected: boolean;
setUser: (user: User | null) => void;
setChats: (chats: Chat[]) => void;
updateChat: (chat: Partial<Chat> & { id: string }) => void;
setActiveChat: (chat: Chat | null) => void;
setMessages: (chatId: string, messages: Message[]) => void;
prependMessages: (chatId: string, messages: Message[]) => void;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (chatId: string, messageId: string) => void;
setOnline: (userId: string, online: boolean) => void;
setTyping: (chatId: string, userId: string, typing: boolean) => void;
setConnected: (v: boolean) => void;
markRead: (chatId: string) => void;
logout: () => void;
}
export const useStore = create<AppStore>((set, get) => ({
user: null,
chats: [],
activeChat: null,
messages: {},
onlineUsers: new Set(),
typingUsers: {},
connected: false,
setUser: (user) => set({ user }),
setChats: (chats) => set({ chats }),
updateChat: (partial) => set(state => ({
chats: state.chats.map(c => c.id === partial.id ? { ...c, ...partial } : c),
activeChat: state.activeChat?.id === partial.id ? { ...state.activeChat, ...partial } : state.activeChat,
})),
setActiveChat: (chat) => set({ activeChat: chat }),
setMessages: (chatId, messages) => set(state => ({
messages: { ...state.messages, [chatId]: messages }
})),
prependMessages: (chatId, messages) => set(state => ({
messages: {
...state.messages,
[chatId]: [...messages, ...(state.messages[chatId] || [])]
}
})),
addMessage: (message) => set(state => {
const existing = state.messages[message.chatId] || [];
if (existing.find(m => m.id === message.id)) return state;
const updated = [...existing, message];
// Update chat last message
const chats = state.chats.map(c =>
c.id === message.chatId
? { ...c, lastMessage: message.content, lastMessageAt: message.createdAt,
unreadCount: state.activeChat?.id === message.chatId ? 0 : c.unreadCount + 1 }
: c
).sort((a, b) => {
const ta = a.lastMessageAt || a.createdAt;
const tb = b.lastMessageAt || b.createdAt;
return new Date(tb).getTime() - new Date(ta).getTime();
});
return { messages: { ...state.messages, [message.chatId]: updated }, chats };
}),
updateMessage: (message) => set(state => ({
messages: {
...state.messages,
[message.chatId]: (state.messages[message.chatId] || []).map(m =>
m.id === message.id ? message : m
)
}
})),
removeMessage: (chatId, messageId) => set(state => ({
messages: {
...state.messages,
[chatId]: (state.messages[chatId] || []).map(m =>
m.id === messageId ? { ...m, isDeleted: true, content: 'Сообщение удалено' } : m
)
}
})),
setOnline: (userId, online) => set(state => {
const next = new Set(state.onlineUsers);
online ? next.add(userId) : next.delete(userId);
return { onlineUsers: next };
}),
setTyping: (chatId, userId, typing) => set(state => {
const current = state.typingUsers[chatId] || [];
const next = typing
? [...new Set([...current, userId])]
: current.filter(id => id !== userId);
return { typingUsers: { ...state.typingUsers, [chatId]: next } };
}),
setConnected: (v) => set({ connected: v }),
markRead: (chatId) => set(state => ({
chats: state.chats.map(c => c.id === chatId ? { ...c, unreadCount: 0 } : c)
})),
logout: () => {
localStorage.removeItem('jc_token');
localStorage.removeItem('jc_user');
set({ user: null, chats: [], activeChat: null, messages: {}, connected: false });
},
}));

74
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,74 @@
export interface User {
id: string;
username: string;
displayName: string;
avatarColor: string;
bio?: string;
phone?: string;
isAdmin: boolean;
lastSeen?: string;
online?: boolean;
}
export interface ChatMember {
id: string;
username: string;
displayName: string;
avatarColor: string;
role: 'owner' | 'admin' | 'member';
online: boolean;
lastSeen: string;
canSendMessages: boolean;
}
export interface Chat {
id: string;
type: 'private' | 'group' | 'channel';
title: string;
description?: string;
avatarColor: string;
isPublic?: boolean;
role: 'owner' | 'admin' | 'member';
lastMessage?: string;
lastMessageAt?: string;
unreadCount: number;
memberCount: number;
privateUserId?: string;
createdAt: string;
members?: ChatMember[];
myRole?: string;
canSendMessages?: boolean;
canAddMembers?: boolean;
}
export interface Attachment {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
url: string;
}
export interface Message {
id: string;
chatId: string;
content: string;
type: 'text' | 'image' | 'file' | 'system';
isDeleted: boolean;
isEdited: boolean;
editedAt?: string;
createdAt: string;
replyTo?: {
id: string;
content: string;
senderName: string;
} | null;
attachments: Attachment[];
sender: {
id: string;
username: string;
displayName: string;
avatarColor: string;
} | null;
}