feat: initial JaniChat messenger — PWA, WebSocket, admin panel
This commit is contained in:
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon-192.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="JaniChat" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>JaniChat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
}
|
||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "janichat-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.383.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
3
frontend/postcss.config.js
Normal file
3
frontend/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "JaniChat",
|
||||
"short_name": "JaniChat",
|
||||
"description": "Корпоративный мессенджер",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "business"]
|
||||
}
|
||||
58
frontend/public/sw.js
Normal file
58
frontend/public/sw.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const CACHE_NAME = 'janichat-v1';
|
||||
const STATIC_ASSETS = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS).catch(() => {}))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) return;
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = { title: 'JaniChat', body: 'Новое сообщение', chatId: null };
|
||||
try { data = event.data.json(); } catch {}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { chatId: data.chatId },
|
||||
vibrate: [200, 100, 200],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const chatId = event.notification.data?.chatId;
|
||||
const url = chatId ? `/?chat=${chatId}` : '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
|
||||
for (const client of clients) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.postMessage({ type: 'open_chat', chatId });
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
95
frontend/src/App.tsx
Normal file
95
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
frontend/src/api/client.ts
Normal file
25
frontend/src/api/client.ts
Normal 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
70
frontend/src/api/ws.ts
Normal 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();
|
||||
34
frontend/src/components/Avatar.tsx
Normal file
34
frontend/src/components/Avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/ChatHeader.tsx
Normal file
131
frontend/src/components/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/ChatInfoPanel.tsx
Normal file
186
frontend/src/components/ChatInfoPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/components/ChatListItem.tsx
Normal file
53
frontend/src/components/ChatListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
144
frontend/src/components/MessageItem.tsx
Normal file
144
frontend/src/components/MessageItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
frontend/src/components/MessageList.tsx
Normal file
180
frontend/src/components/MessageList.tsx
Normal 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';
|
||||
227
frontend/src/components/NewChatModal.tsx
Normal file
227
frontend/src/components/NewChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
frontend/src/components/admin/AdminPanel.tsx
Normal file
197
frontend/src/components/admin/AdminPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/hooks/usePushNotifications.ts
Normal file
46
frontend/src/hooks/usePushNotifications.ts
Normal 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
37
frontend/src/index.css
Normal 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
17
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
94
frontend/src/pages/Login.tsx
Normal file
94
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
frontend/src/pages/MainLayout.tsx
Normal file
206
frontend/src/pages/MainLayout.tsx
Normal 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
121
frontend/src/store/index.ts
Normal 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
74
frontend/src/types.ts
Normal 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;
|
||||
}
|
||||
6
frontend/tailwind.config.js
Normal file
6
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
||||
'/ws': { target: 'ws://localhost:3001', ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user