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

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
DATABASE_URL=postgresql://hotelsync:PASSWORD@hotelsync-postgres:5432/janichat
JWT_SECRET=your-jwt-secret-here
VAPID_PUBLIC_KEY=your-vapid-public-key
VAPID_PRIVATE_KEY=your-vapid-private-key
VAPID_EMAIL=admin@janichat.ru
ALLOWED_ORIGINS=https://janichat.ru,https://www.janichat.ru
PORT=3000

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.env.local
uploads/
.DS_Store

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

29
backend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "janichat-api",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@fastify/jwt": "^8.0.1",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^8.3.1",
"bcryptjs": "^2.4.3",
"fastify": "^4.28.1",
"pg": "^8.12.0",
"web-push": "^3.6.7"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.6",
"@types/web-push": "^3.6.4",
"tsx": "^4.15.7",
"typescript": "^5.4.5"
}
}

99
backend/src/db.ts Normal file
View File

@@ -0,0 +1,99 @@
import { Pool } from 'pg';
import bcrypt from 'bcryptjs';
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function initDB() {
await pool.query(`
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
password_hash TEXT NOT NULL,
avatar_color VARCHAR(7) DEFAULT '#3b82f6',
bio TEXT DEFAULT '',
phone VARCHAR(20) DEFAULT '',
is_admin BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
last_seen TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS chats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(20) NOT NULL CHECK (type IN ('private','group','channel')),
title VARCHAR(200),
description TEXT DEFAULT '',
avatar_color VARCHAR(7) DEFAULT '#8b5cf6',
is_public BOOLEAN DEFAULT FALSE,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS chat_members (
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','member')),
can_send_messages BOOLEAN DEFAULT TRUE,
can_send_media BOOLEAN DEFAULT TRUE,
can_add_members BOOLEAN DEFAULT FALSE,
can_pin_messages BOOLEAN DEFAULT FALSE,
is_muted BOOLEAN DEFAULT FALSE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (chat_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
sender_id UUID REFERENCES users(id),
content TEXT NOT NULL DEFAULT '',
type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (type IN ('text','image','file','system')),
reply_to_id UUID REFERENCES messages(id),
is_edited BOOLEAN DEFAULT FALSE,
edited_at TIMESTAMPTZ,
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size INTEGER NOT NULL,
url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
auth_key TEXT NOT NULL,
p256dh TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (endpoint)
);
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_chat_members_user_id ON chat_members(user_id);
`);
// Seed admin if no users
const { rows } = await pool.query('SELECT COUNT(*) FROM users');
if (parseInt(rows[0].count) === 0) {
const hash = await bcrypt.hash('admin123', 10);
await pool.query(
`INSERT INTO users (username, display_name, password_hash, is_admin) VALUES ($1,$2,$3,TRUE)`,
['admin', 'Администратор', hash]
);
console.log('Created admin user: admin / admin123');
}
}

71
backend/src/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import Fastify from 'fastify';
import fastifyJwt from '@fastify/jwt';
import fastifyCors from '@fastify/cors';
import fastifyWebSocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import webpush from 'web-push';
import path from 'path';
import { initDB } from './db.js';
import { setupWebSocket } from './ws.js';
import authRoutes from './routes/auth.js';
import adminRoutes from './routes/admin.js';
import chatRoutes from './routes/chats.js';
import messageRoutes from './routes/messages.js';
import userRoutes from './routes/users.js';
import pushRoutes from './routes/push.js';
const app = Fastify({ logger: true });
// VAPID
webpush.setVapidDetails(
`mailto:${process.env.VAPID_EMAIL || 'admin@janichat.ru'}`,
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
// Plugins
await app.register(fastifyCors, {
origin: (process.env.ALLOWED_ORIGINS || '').split(','),
credentials: true,
});
await app.register(fastifyJwt, { secret: process.env.JWT_SECRET! });
await app.register(fastifyWebSocket);
await app.register(fastifyMultipart, {
limits: { fileSize: 50 * 1024 * 1024 },
});
await app.register(fastifyStatic, {
root: '/uploads',
prefix: '/uploads/',
});
// Auth decorator
app.decorate('authenticate', async function(request: any, reply: any) {
try {
await request.jwtVerify();
} catch {
reply.status(401).send({ error: 'Unauthorized' });
}
});
// Routes
await app.register(authRoutes, { prefix: '/api/auth' });
await app.register(adminRoutes, { prefix: '/api/admin' });
await app.register(chatRoutes, { prefix: '/api/chats' });
await app.register(messageRoutes, { prefix: '/api/messages' });
await app.register(userRoutes, { prefix: '/api/users' });
await app.register(pushRoutes, { prefix: '/api/push' });
// Health check
app.get('/health', async () => ({ ok: true }));
// WebSocket
setupWebSocket(app);
// Init
await initDB();
await app.listen({ port: parseInt(process.env.PORT || '3000'), host: '0.0.0.0' });

101
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,101 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
async function requireAdmin(req: any, reply: any) {
const { isAdmin } = req.user as { isAdmin: boolean };
if (!isAdmin) return reply.status(403).send({ error: 'Forbidden' });
}
export default async function adminRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.addHook('preHandler', requireAdmin);
// List users
app.get('/users', async () => {
const { rows } = await pool.query(
`SELECT id, username, display_name, avatar_color, is_admin, is_active, last_seen, created_at
FROM users ORDER BY created_at DESC`
);
return rows.map(u => ({
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, isAdmin: u.is_admin, isActive: u.is_active,
lastSeen: u.last_seen, createdAt: u.created_at,
}));
});
// Create user
app.post('/users', async (req, reply) => {
const { username, displayName, password, isAdmin } = req.body as any;
const { id: createdBy } = req.user as { id: string };
if (!username || !displayName || !password) {
return reply.status(400).send({ error: 'Заполните все поля' });
}
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const hash = await bcrypt.hash(password, 10);
try {
const { rows: [user] } = await pool.query(
`INSERT INTO users (username, display_name, password_hash, avatar_color, is_admin, created_by)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, username, display_name, avatar_color, is_admin`,
[username, displayName, hash, color, !!isAdmin, createdBy]
);
return reply.status(201).send({
id: user.id, username: user.username, displayName: user.display_name,
avatarColor: user.avatar_color, isAdmin: user.is_admin,
});
} catch (e: any) {
if (e.code === '23505') return reply.status(400).send({ error: 'Логин уже занят' });
throw e;
}
});
// Update user
app.put('/users/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { displayName, isAdmin, isActive, password } = req.body as any;
if (password) {
const hash = await bcrypt.hash(password, 10);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
}
await pool.query(
`UPDATE users SET
display_name = COALESCE($1, display_name),
is_admin = COALESCE($2, is_admin),
is_active = COALESCE($3, is_active)
WHERE id = $4`,
[displayName, isAdmin, isActive, id]
);
return { ok: true };
});
// Delete user
app.delete('/users/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { id: selfId } = req.user as { id: string };
if (id === selfId) return reply.status(400).send({ error: 'Нельзя удалить себя' });
await pool.query('DELETE FROM users WHERE id = $1', [id]);
return { ok: true };
});
// Stats
app.get('/stats', async () => {
const [users, chats, messages] = await Promise.all([
pool.query('SELECT COUNT(*) FROM users'),
pool.query('SELECT COUNT(*) FROM chats'),
pool.query('SELECT COUNT(*) FROM messages WHERE is_deleted = FALSE'),
]);
return {
users: parseInt(users.rows[0].count),
chats: parseInt(chats.rows[0].count),
messages: parseInt(messages.rows[0].count),
};
});
}

View File

@@ -0,0 +1,76 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
export default async function authRoutes(app: FastifyInstance) {
app.post('/login', async (req, reply) => {
const { username, password } = req.body as { username: string; password: string };
const { rows: [user] } = await pool.query(
'SELECT * FROM users WHERE username = $1 AND is_active = TRUE', [username]
);
if (!user) return reply.status(401).send({ error: 'Неверный логин или пароль' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return reply.status(401).send({ error: 'Неверный логин или пароль' });
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [user.id]);
const token = app.jwt.sign({ id: user.id, isAdmin: user.is_admin }, { expiresIn: '30d' });
return {
token,
user: {
id: user.id,
username: user.username,
displayName: user.display_name,
avatarColor: user.avatar_color,
bio: user.bio,
isAdmin: user.is_admin,
}
};
});
app.get('/me', { preHandler: [app.authenticate] }, async (req) => {
const { id } = req.user as { id: string };
const { rows: [user] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin, last_seen FROM users WHERE id = $1', [id]
);
return {
id: user.id,
username: user.username,
displayName: user.display_name,
avatarColor: user.avatar_color,
bio: user.bio,
phone: user.phone,
isAdmin: user.is_admin,
lastSeen: user.last_seen,
};
});
app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => {
const { id } = req.user as { id: string };
const { displayName, bio, phone, password, newPassword } = req.body as any;
if (newPassword) {
const { rows: [user] } = await pool.query('SELECT password_hash FROM users WHERE id = $1', [id]);
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return reply.status(400).send({ error: 'Неверный текущий пароль' });
const hash = await bcrypt.hash(newPassword, 10);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
}
await pool.query(
'UPDATE users SET display_name = COALESCE($1, display_name), bio = COALESCE($2, bio), phone = COALESCE($3, phone) WHERE id = $4',
[displayName, bio, phone, id]
);
const { rows: [user] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, phone, is_admin FROM users WHERE id = $1', [id]
);
return {
id: user.id, username: user.username, displayName: user.display_name,
avatarColor: user.avatar_color, bio: user.bio, phone: user.phone, isAdmin: user.is_admin,
};
});
}

275
backend/src/routes/chats.ts Normal file
View File

@@ -0,0 +1,275 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
import { connections } from '../ws.js';
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
export default async function chatRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
// Get my chats
app.get('/', async (req) => {
const { id: userId } = req.user as { id: string };
const { rows } = await pool.query(`
SELECT
c.id, c.type, c.title, c.description, c.avatar_color, c.is_public, c.created_at,
cm.role, cm.last_read_at,
(SELECT content FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE chat_id = c.id AND is_deleted = FALSE ORDER BY created_at DESC LIMIT 1) AS last_message_at,
(SELECT COUNT(*) FROM messages WHERE chat_id = c.id AND is_deleted = FALSE AND created_at > cm.last_read_at) AS unread_count,
(SELECT COUNT(*) FROM chat_members WHERE chat_id = c.id) AS member_count,
-- For private chats, get the other user
CASE WHEN c.type = 'private' THEN (
SELECT u.display_name FROM users u
JOIN chat_members cm2 ON cm2.user_id = u.id
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
) END AS private_name,
CASE WHEN c.type = 'private' THEN (
SELECT u.avatar_color FROM users u
JOIN chat_members cm2 ON cm2.user_id = u.id
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
) END AS private_color,
CASE WHEN c.type = 'private' THEN (
SELECT u.id FROM users u
JOIN chat_members cm2 ON cm2.user_id = u.id
WHERE cm2.chat_id = c.id AND cm2.user_id != $1 LIMIT 1
) END AS private_user_id
FROM chats c
JOIN chat_members cm ON cm.chat_id = c.id AND cm.user_id = $1
ORDER BY COALESCE(last_message_at, c.created_at) DESC
`, [userId]);
return rows.map(r => ({
id: r.id,
type: r.type,
title: r.type === 'private' ? r.private_name : r.title,
description: r.description,
avatarColor: r.type === 'private' ? r.private_color : r.avatar_color,
isPublic: r.is_public,
role: r.role,
lastMessage: r.last_message,
lastMessageAt: r.last_message_at,
unreadCount: parseInt(r.unread_count),
memberCount: parseInt(r.member_count),
privateUserId: r.private_user_id,
createdAt: r.created_at,
}));
});
// Get chat by id (with members)
app.get('/:id', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
const { rows: [member] } = await pool.query(
'SELECT role, can_send_messages, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2',
[id, userId]
);
if (!member) return reply.status(403).send({ error: 'Not a member' });
const { rows: [chat] } = await pool.query('SELECT * FROM chats WHERE id = $1', [id]);
if (!chat) return reply.status(404).send({ error: 'Not found' });
const { rows: members } = await pool.query(`
SELECT u.id, u.username, u.display_name, u.avatar_color, u.last_seen, cm.role, cm.can_send_messages
FROM chat_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.chat_id = $1
ORDER BY cm.role DESC, u.display_name
`, [id]);
const onlineSet = new Set(connections.keys());
return {
id: chat.id,
type: chat.type,
title: chat.title,
description: chat.description,
avatarColor: chat.avatar_color,
isPublic: chat.is_public,
myRole: member.role,
canSendMessages: member.can_send_messages,
canAddMembers: member.can_add_members,
createdAt: chat.created_at,
members: members.map(m => ({
id: m.id, username: m.username, displayName: m.display_name,
avatarColor: m.avatar_color, role: m.role, online: onlineSet.has(m.id),
lastSeen: m.last_seen, canSendMessages: m.can_send_messages,
})),
};
});
// Start or get private chat
app.post('/private/:userId', async (req, reply) => {
const { id: myId } = req.user as { id: string };
const { userId } = req.params as { userId: string };
if (myId === userId) return reply.status(400).send({ error: 'Cannot chat with yourself' });
// Check if private chat already exists
const { rows } = await pool.query(`
SELECT c.id FROM chats c
JOIN chat_members cm1 ON cm1.chat_id = c.id AND cm1.user_id = $1
JOIN chat_members cm2 ON cm2.chat_id = c.id AND cm2.user_id = $2
WHERE c.type = 'private'
LIMIT 1
`, [myId, userId]);
if (rows.length > 0) return { id: rows[0].id };
// Create new private chat
const { rows: [target] } = await pool.query('SELECT id FROM users WHERE id = $1', [userId]);
if (!target) return reply.status(404).send({ error: 'User not found' });
const { rows: [chat] } = await pool.query(
`INSERT INTO chats (type, created_by) VALUES ('private', $1) RETURNING id`, [myId]
);
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member'),($1,$3,'member')`,
[chat.id, myId, userId]
);
return reply.status(201).send({ id: chat.id });
});
// Create group or channel
app.post('/', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { type, title, description, isPublic, memberIds } = req.body as any;
if (!['group', 'channel'].includes(type)) return reply.status(400).send({ error: 'Invalid type' });
if (!title) return reply.status(400).send({ error: 'Title required' });
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const { rows: [chat] } = await pool.query(
`INSERT INTO chats (type, title, description, avatar_color, is_public, created_by)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[type, title, description || '', color, !!isPublic, userId]
);
// Add creator as owner
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role, can_add_members, can_pin_messages)
VALUES ($1,$2,'owner',TRUE,TRUE)`,
[chat.id, userId]
);
// Add other members
if (Array.isArray(memberIds) && memberIds.length > 0) {
for (const mid of memberIds) {
if (mid === userId) continue;
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`,
[chat.id, mid]
);
}
}
return reply.status(201).send({ id: chat.id });
});
// Update chat
app.put('/:id', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
const { title, description } = req.body as any;
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || !['owner', 'admin'].includes(member.role)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query(
'UPDATE chats SET title = COALESCE($1, title), description = COALESCE($2, description) WHERE id = $3',
[title, description, id]
);
return { ok: true };
});
// Add member
app.post('/:id/members', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
const { memberId } = req.body as { memberId: string };
const { rows: [member] } = await pool.query(
'SELECT role, can_add_members FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || (!member.can_add_members && !['owner', 'admin'].includes(member.role))) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query(
`INSERT INTO chat_members (chat_id, user_id, role) VALUES ($1,$2,'member') ON CONFLICT DO NOTHING`,
[id, memberId]
);
return { ok: true };
});
// Remove member
app.delete('/:id/members/:memberId', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id, memberId } = req.params as { id: string; memberId: string };
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || (!['owner', 'admin'].includes(member.role) && userId !== memberId)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, memberId]);
return { ok: true };
});
// Update member role/permissions
app.put('/:id/members/:memberId', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id, memberId } = req.params as { id: string; memberId: string };
const { role, canSendMessages } = req.body as any;
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || !['owner', 'admin'].includes(member.role)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query(
`UPDATE chat_members SET
role = COALESCE($1, role),
can_send_messages = COALESCE($2, can_send_messages)
WHERE chat_id = $3 AND user_id = $4`,
[role, canSendMessages, id, memberId]
);
return { ok: true };
});
// Leave chat
app.delete('/:id/leave', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { id } = req.params as { id: string };
await pool.query('DELETE FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]);
return { ok: true };
});
// Delete chat (owner only)
app.delete('/:id', async (req, reply) => {
const { id: userId, isAdmin } = req.user as { id: string; isAdmin: boolean };
const { id } = req.params as { id: string };
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [id, userId]
);
if (!member || (member.role !== 'owner' && !isAdmin)) {
return reply.status(403).send({ error: 'No permission' });
}
await pool.query('DELETE FROM chats WHERE id = $1', [id]);
return { ok: true };
});
}

View File

@@ -0,0 +1,137 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
export default async function messageRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
// Get messages for a chat
app.get('/chat/:chatId', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { chatId } = req.params as { chatId: string };
const { before, limit = '50' } = req.query as any;
const { rows: [member] } = await pool.query(
'SELECT 1 FROM chat_members WHERE chat_id = $1 AND user_id = $2', [chatId, userId]
);
if (!member) return reply.status(403).send({ error: 'Not a member' });
const lim = Math.min(parseInt(limit), 100);
let query: string;
let params: any[];
if (before) {
query = `
SELECT m.*, u.username, u.display_name, u.avatar_color,
rm.content AS reply_content,
ru.display_name AS reply_sender_name,
(SELECT json_agg(json_build_object('id',a.id,'filename',a.filename,'originalName',a.original_name,'mimeType',a.mime_type,'size',a.size,'url',a.url))
FROM attachments a WHERE a.message_id = m.id) AS attachments
FROM messages m
LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN messages rm ON rm.id = m.reply_to_id
LEFT JOIN users ru ON ru.id = rm.sender_id
WHERE m.chat_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
ORDER BY m.created_at DESC LIMIT $3
`;
params = [chatId, before, lim];
} else {
query = `
SELECT m.*, u.username, u.display_name, u.avatar_color,
rm.content AS reply_content,
ru.display_name AS reply_sender_name,
(SELECT json_agg(json_build_object('id',a.id,'filename',a.filename,'originalName',a.original_name,'mimeType',a.mime_type,'size',a.size,'url',a.url))
FROM attachments a WHERE a.message_id = m.id) AS attachments
FROM messages m
LEFT JOIN users u ON u.id = m.sender_id
LEFT JOIN messages rm ON rm.id = m.reply_to_id
LEFT JOIN users ru ON ru.id = rm.sender_id
WHERE m.chat_id = $1
ORDER BY m.created_at DESC LIMIT $2
`;
params = [chatId, lim];
}
const { rows } = await pool.query(query, params);
// Update last_read_at
await pool.query(
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
[chatId, userId]
);
return rows.reverse().map(m => ({
id: m.id,
chatId: m.chat_id,
content: m.content,
type: m.type,
isDeleted: m.is_deleted,
isEdited: m.is_edited,
editedAt: m.edited_at,
createdAt: m.created_at,
replyTo: m.reply_to_id ? {
id: m.reply_to_id,
content: m.reply_content,
senderName: m.reply_sender_name,
} : null,
attachments: m.attachments || [],
sender: m.sender_id ? {
id: m.sender_id,
username: m.username,
displayName: m.display_name,
avatarColor: m.avatar_color,
} : null,
}));
});
// Upload file and send as message
app.post('/chat/:chatId/upload', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { chatId } = req.params as { chatId: string };
const { rows: [member] } = await pool.query(
'SELECT can_send_media FROM chat_members WHERE chat_id = $1 AND user_id = $2', [chatId, userId]
);
if (!member) return reply.status(403).send({ error: 'Not a member' });
if (!member.can_send_media) return reply.status(403).send({ error: 'No media permission' });
const data = await req.file();
if (!data) return reply.status(400).send({ error: 'No file' });
const ext = path.extname(data.filename);
const filename = `${crypto.randomUUID()}${ext}`;
const uploadDir = '/uploads';
fs.mkdirSync(uploadDir, { recursive: true });
const filepath = path.join(uploadDir, filename);
const buffer = await data.toBuffer();
fs.writeFileSync(filepath, buffer);
const isImage = data.mimetype.startsWith('image/');
const msgType = isImage ? 'image' : 'file';
const url = `/uploads/${filename}`;
const { rows: [msg] } = await pool.query(
`INSERT INTO messages (chat_id, sender_id, content, type) VALUES ($1,$2,$3,$4) RETURNING *`,
[chatId, userId, data.filename, msgType]
);
await pool.query(
`INSERT INTO attachments (message_id, filename, original_name, mime_type, size, url)
VALUES ($1,$2,$3,$4,$5,$6)`,
[msg.id, filename, data.filename, data.mimetype, buffer.length, url]
);
const { rows: [sender] } = await pool.query(
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId]
);
return {
id: msg.id, chatId: msg.chat_id, content: msg.content, type: msg.type,
createdAt: msg.created_at, sender,
attachments: [{ filename, originalName: data.filename, mimeType: data.mimetype, size: buffer.length, url }],
};
});
}

View File

@@ -0,0 +1,30 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
export default async function pushRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
app.get('/vapid-public-key', async () => {
return { key: process.env.VAPID_PUBLIC_KEY };
});
app.post('/subscribe', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { endpoint, keys } = req.body as { endpoint: string; keys: { auth: string; p256dh: string } };
await pool.query(
`INSERT INTO push_subscriptions (user_id, endpoint, auth_key, p256dh)
VALUES ($1,$2,$3,$4)
ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, auth_key = $3, p256dh = $4`,
[userId, endpoint, keys.auth, keys.p256dh]
);
return reply.status(201).send({ ok: true });
});
app.delete('/subscribe', async (req, reply) => {
const { id: userId } = req.user as { id: string };
const { endpoint } = req.body as { endpoint: string };
await pool.query('DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2', [userId, endpoint]);
return { ok: true };
});
}

View File

@@ -0,0 +1,55 @@
import { FastifyInstance } from 'fastify';
import { pool } from '../db.js';
import { connections } from '../ws.js';
export default async function userRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
// Search users
app.get('/search', async (req) => {
const { q } = req.query as { q: string };
const { id: userId } = req.user as { id: string };
const { rows } = await pool.query(
`SELECT id, username, display_name, avatar_color, last_seen FROM users
WHERE is_active = TRUE AND id != $1
AND (username ILIKE $2 OR display_name ILIKE $2)
ORDER BY display_name LIMIT 20`,
[userId, `%${q}%`]
);
const online = connections;
return rows.map(u => ({
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen,
}));
});
// Get all users (for add to chat)
app.get('/', async (req) => {
const { id: userId } = req.user as { id: string };
const { rows } = await pool.query(
`SELECT id, username, display_name, avatar_color, last_seen FROM users
WHERE is_active = TRUE AND id != $1 ORDER BY display_name`,
[userId]
);
const online = connections;
return rows.map(u => ({
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, online: online.has(u.id), lastSeen: u.last_seen,
}));
});
// Get user profile
app.get('/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const { rows: [u] } = await pool.query(
'SELECT id, username, display_name, avatar_color, bio, last_seen FROM users WHERE id = $1 AND is_active = TRUE', [id]
);
if (!u) return reply.status(404).send({ error: 'Not found' });
return {
id: u.id, username: u.username, displayName: u.display_name,
avatarColor: u.avatar_color, bio: u.bio, online: connections.has(u.id), lastSeen: u.last_seen,
};
});
}

202
backend/src/ws.ts Normal file
View File

@@ -0,0 +1,202 @@
import { FastifyInstance } from 'fastify';
import { WebSocket } from 'ws';
import { pool } from './db.js';
import webpush from 'web-push';
// userId -> Set of WebSocket connections
export const connections = new Map<string, Set<WebSocket>>();
function broadcast(userIds: string[], data: object) {
const msg = JSON.stringify(data);
for (const uid of userIds) {
const sockets = connections.get(uid);
if (sockets) {
sockets.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
});
}
}
}
async function getChatMemberIds(chatId: string): Promise<string[]> {
const { rows } = await pool.query(
'SELECT user_id FROM chat_members WHERE chat_id = $1', [chatId]
);
return rows.map(r => r.user_id);
}
async function sendPushToOfflineUsers(userIds: string[], payload: object) {
const online = new Set(connections.keys());
const offline = userIds.filter(id => !online.has(id));
if (offline.length === 0) return;
const { rows } = await pool.query(
'SELECT endpoint, auth_key, p256dh FROM push_subscriptions WHERE user_id = ANY($1)',
[offline]
);
const body = JSON.stringify(payload);
for (const sub of rows) {
try {
await webpush.sendNotification(
{ endpoint: sub.endpoint, keys: { auth: sub.auth_key, p256dh: sub.p256dh } },
body
);
} catch (e: any) {
if (e.statusCode === 410) {
await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]);
}
}
}
}
export function setupWebSocket(app: FastifyInstance) {
app.get('/ws', { websocket: true }, async (socket, req) => {
let userId: string | null = null;
try {
const token = (req.query as any).token;
const decoded = app.jwt.verify<{ id: string }>(token);
userId = decoded.id;
} catch {
socket.close(1008, 'Unauthorized');
return;
}
// Register connection
if (!connections.has(userId)) connections.set(userId, new Set());
connections.get(userId)!.add(socket);
// Update last_seen
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [userId]);
// Notify others that user is online
const { rows: memberChats } = await pool.query(
'SELECT DISTINCT chat_id FROM chat_members WHERE user_id = $1', [userId]
);
for (const row of memberChats) {
const ids = await getChatMemberIds(row.chat_id);
broadcast(ids.filter(id => id !== userId), { type: 'user_online', payload: { userId, online: true } });
}
socket.on('message', async (raw) => {
try {
const { type, payload } = JSON.parse(raw.toString());
if (type === 'typing') {
const members = await getChatMemberIds(payload.chatId);
broadcast(members.filter(id => id !== userId), {
type: 'typing',
payload: { chatId: payload.chatId, userId, typing: payload.typing }
});
}
if (type === 'read_messages') {
await pool.query(
`UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2`,
[payload.chatId, userId]
);
const members = await getChatMemberIds(payload.chatId);
broadcast(members.filter(id => id !== userId), {
type: 'messages_read',
payload: { chatId: payload.chatId, userId }
});
}
if (type === 'send_message') {
// Check membership
const { rows: [member] } = await pool.query(
'SELECT role, can_send_messages FROM chat_members WHERE chat_id = $1 AND user_id = $2',
[payload.chatId, userId]
);
if (!member) return;
const { rows: [chat] } = await pool.query('SELECT type FROM chats WHERE id = $1', [payload.chatId]);
// Channels: only owner/admin can send
if (chat.type === 'channel' && !['owner', 'admin'].includes(member.role)) return;
if (!member.can_send_messages && member.role === 'member') return;
const { rows: [msg] } = await pool.query(
`INSERT INTO messages (chat_id, sender_id, content, type, reply_to_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[payload.chatId, userId, payload.content, payload.type || 'text', payload.replyToId || null]
);
// Get sender info
const { rows: [sender] } = await pool.query(
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId]
);
const fullMsg = { ...msg, sender };
const members = await getChatMemberIds(payload.chatId);
broadcast(members, { type: 'new_message', payload: fullMsg });
// Push to offline users
await sendPushToOfflineUsers(members, {
title: sender.display_name,
body: payload.content.substring(0, 100),
chatId: payload.chatId
});
// Update sender's last_read_at
await pool.query(
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
[payload.chatId, userId]
);
}
if (type === 'delete_message') {
const { rows: [msg] } = await pool.query(
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
);
if (!msg) return;
const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [msg.chat_id, userId]
);
if (msg.sender_id !== userId && !['owner', 'admin'].includes(member?.role)) return;
await pool.query('UPDATE messages SET is_deleted = TRUE WHERE id = $1', [payload.messageId]);
const members = await getChatMemberIds(msg.chat_id);
broadcast(members, { type: 'message_deleted', payload: { messageId: payload.messageId, chatId: msg.chat_id } });
}
if (type === 'edit_message') {
const { rows: [msg] } = await pool.query(
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
);
if (!msg || msg.sender_id !== userId) return;
const { rows: [updated] } = await pool.query(
'UPDATE messages SET content = $1, is_edited = TRUE, edited_at = NOW() WHERE id = $2 RETURNING *',
[payload.content, payload.messageId]
);
const members = await getChatMemberIds(msg.chat_id);
broadcast(members, { type: 'message_edited', payload: updated });
}
} catch (e) {
console.error('WS message error:', e);
}
});
socket.on('close', async () => {
if (!userId) return;
connections.get(userId)!.delete(socket);
if (connections.get(userId)!.size === 0) {
connections.delete(userId);
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [userId]);
const { rows: memberChats } = await pool.query(
'SELECT DISTINCT chat_id FROM chat_members WHERE user_id = $1', [userId]
);
for (const row of memberChats) {
const ids = await getChatMemberIds(row.chat_id);
broadcast(ids, { type: 'user_online', payload: { userId, online: false } });
}
}
});
});
}

17
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

17
deploy.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -e
echo "=== JaniChat Deploy ==="
cd /opt/janichat
# Pull latest code
cd app
git pull origin main
cd ..
# Rebuild and restart
docker compose build --no-cache
docker compose up -d
echo "=== Done ==="
docker compose ps

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
janichat-api:
build:
context: ./backend
dockerfile: Dockerfile
image: janichat-api:latest
container_name: janichat-api
restart: unless-stopped
env_file: .env
volumes:
- uploads_data:/uploads
networks:
- hotelsync_hotelsync-net
janichat-frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: janichat-frontend:latest
container_name: janichat-frontend
restart: unless-stopped
networks:
- hotelsync_hotelsync-net
volumes:
uploads_data:
networks:
hotelsync_hotelsync-net:
external: true

14
frontend/Dockerfile Normal file
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};

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

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

58
nginx-janichat.conf Normal file
View File

@@ -0,0 +1,58 @@
# ── janichat.ru — HTTP → HTTPS redirect (добавить в существующий server 80) ──
# Добавить в server { listen 80; server_name ... }:
# server_name ... janichat.ru www.janichat.ru api.janichat.ru;
# ── janichat.ru + www — Frontend ─────────────────────────────────────────
server {
listen 443 ssl;
server_name janichat.ru www.janichat.ru;
ssl_certificate /etc/letsencrypt/live/janichat.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/janichat.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://janichat-frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ── api.janichat.ru — Backend API + WebSocket ─────────────────────────────
server {
listen 443 ssl;
server_name api.janichat.ru;
ssl_certificate /etc/letsencrypt/live/api.janichat.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.janichat.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /ws {
proxy_pass http://janichat-api:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
location /uploads/ {
proxy_pass http://janichat-api:3000;
proxy_set_header Host $host;
add_header Cache-Control "public, max-age=31536000";
}
location / {
proxy_pass http://janichat-api:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}