feat: initial JaniChat messenger — PWA, WebSocket, admin panel
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
uploads/
|
||||||
|
.DS_Store
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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
29
backend/package.json
Normal 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
99
backend/src/db.ts
Normal 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
71
backend/src/index.ts
Normal 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
101
backend/src/routes/admin.ts
Normal 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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
76
backend/src/routes/auth.ts
Normal file
76
backend/src/routes/auth.ts
Normal 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
275
backend/src/routes/chats.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
137
backend/src/routes/messages.ts
Normal file
137
backend/src/routes/messages.ts
Normal 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 }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
30
backend/src/routes/push.ts
Normal file
30
backend/src/routes/push.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
55
backend/src/routes/users.ts
Normal file
55
backend/src/routes/users.ts
Normal 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
202
backend/src/ws.ts
Normal 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
17
backend/tsconfig.json
Normal 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
17
deploy.sh
Normal 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
31
docker-compose.yml
Normal 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
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon-192.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#3b82f6" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="JaniChat" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>JaniChat</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sw.js {
|
||||||
|
expires off;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "janichat-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.383.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/postcss.config.js
Normal file
3
frontend/postcss.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
25
frontend/public/manifest.json
Normal file
25
frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "JaniChat",
|
||||||
|
"short_name": "JaniChat",
|
||||||
|
"description": "Корпоративный мессенджер",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#3b82f6",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "business"]
|
||||||
|
}
|
||||||
58
frontend/public/sw.js
Normal file
58
frontend/public/sw.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const CACHE_NAME = 'janichat-v1';
|
||||||
|
const STATIC_ASSETS = ['/', '/index.html'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
self.skipWaiting();
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS).catch(() => {}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) return;
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request).catch(() => caches.match(event.request))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
let data = { title: 'JaniChat', body: 'Новое сообщение', chatId: null };
|
||||||
|
try { data = event.data.json(); } catch {}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: '/icon-192.png',
|
||||||
|
badge: '/icon-192.png',
|
||||||
|
data: { chatId: data.chatId },
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const chatId = event.notification.data?.chatId;
|
||||||
|
const url = chatId ? `/?chat=${chatId}` : '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
|
client.postMessage({ type: 'open_chat', chatId });
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
95
frontend/src/App.tsx
Normal file
95
frontend/src/App.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import LoginPage from './pages/Login';
|
||||||
|
import MainLayout from './pages/MainLayout';
|
||||||
|
import { useStore } from './store';
|
||||||
|
import { wsClient } from './api/ws';
|
||||||
|
import api from './api/client';
|
||||||
|
|
||||||
|
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = useStore(s => s.user);
|
||||||
|
return user ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { setUser, setChats, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Restore session
|
||||||
|
const token = localStorage.getItem('jc_token');
|
||||||
|
const userStr = localStorage.getItem('jc_user');
|
||||||
|
if (token && userStr) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(userStr));
|
||||||
|
connectWS(token);
|
||||||
|
loadChats();
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('jc_token');
|
||||||
|
localStorage.removeItem('jc_user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadChats() {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get('/api/chats');
|
||||||
|
setChats(data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWS(token: string) {
|
||||||
|
wsClient.disconnect();
|
||||||
|
wsClient.connect(token);
|
||||||
|
|
||||||
|
wsClient.on('connected', () => setConnected(true));
|
||||||
|
wsClient.on('disconnected', () => setConnected(false));
|
||||||
|
|
||||||
|
wsClient.on('new_message', (msg) => {
|
||||||
|
addMessage(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.on('message_edited', (msg) => {
|
||||||
|
updateMessage(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.on('message_deleted', ({ messageId, chatId }) => {
|
||||||
|
removeMessage(chatId, messageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.on('user_online', ({ userId, online }) => {
|
||||||
|
setOnline(userId, online);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.on('typing', ({ chatId, userId, typing }) => {
|
||||||
|
setTyping(chatId, userId, typing);
|
||||||
|
});
|
||||||
|
|
||||||
|
wsClient.on('messages_read', ({ chatId }) => {
|
||||||
|
markRead(chatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle SW notification click
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'open_chat') {
|
||||||
|
// Handled by MainLayout
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage onLogin={(token, user) => {
|
||||||
|
localStorage.setItem('jc_token', token);
|
||||||
|
localStorage.setItem('jc_user', JSON.stringify(user));
|
||||||
|
setUser(user);
|
||||||
|
connectWS(token);
|
||||||
|
loadChats();
|
||||||
|
}} />} />
|
||||||
|
<Route path="/*" element={<AuthGuard><MainLayout /></AuthGuard>} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/api/client.ts
Normal file
25
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('jc_token');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('jc_token');
|
||||||
|
localStorage.removeItem('jc_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
70
frontend/src/api/ws.ts
Normal file
70
frontend/src/api/ws.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
type Handler = (payload: any) => void;
|
||||||
|
|
||||||
|
class WsClient {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private handlers = new Map<string, Handler[]>();
|
||||||
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
connect(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
const wsBase = import.meta.env.VITE_WS_URL || `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`;
|
||||||
|
this.ws = new WebSocket(`${wsBase}/ws?token=${token}`);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
this.emit('connected', null);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const { type, payload } = JSON.parse(event.data);
|
||||||
|
const hs = this.handlers.get(type) || [];
|
||||||
|
hs.forEach(h => h(payload));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.emit('disconnected', null);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
if (this.token) this.connect(this.token);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = () => {
|
||||||
|
this.ws?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
on(type: string, handler: Handler) {
|
||||||
|
if (!this.handlers.has(type)) this.handlers.set(type, []);
|
||||||
|
this.handlers.get(type)!.push(handler);
|
||||||
|
return () => {
|
||||||
|
const hs = this.handlers.get(type) || [];
|
||||||
|
this.handlers.set(type, hs.filter(h => h !== handler));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(type: string, payload: any) {
|
||||||
|
const hs = this.handlers.get(type) || [];
|
||||||
|
hs.forEach(h => h(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
send(type: string, payload: any) {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify({ type, payload }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.token = null;
|
||||||
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
this.ws?.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const wsClient = new WsClient();
|
||||||
34
frontend/src/components/Avatar.tsx
Normal file
34
frontend/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
online?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-8 h-8 text-xs',
|
||||||
|
md: 'w-10 h-10 text-sm',
|
||||||
|
lg: 'w-12 h-12 text-base',
|
||||||
|
xl: 'w-16 h-16 text-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Avatar({ name, color, size = 'md', online, className = '' }: Props) {
|
||||||
|
const initials = name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative flex-shrink-0 ${className}`}>
|
||||||
|
<div
|
||||||
|
className={`${sizes[size]} rounded-full flex items-center justify-center font-semibold text-white select-none`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
{online !== undefined && (
|
||||||
|
<div className={`absolute bottom-0 right-0 rounded-full border-2 border-white ${
|
||||||
|
online ? 'bg-green-400' : 'bg-gray-300'
|
||||||
|
} ${size === 'sm' ? 'w-2.5 h-2.5' : 'w-3 h-3'}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
frontend/src/components/ChatHeader.tsx
Normal file
131
frontend/src/components/ChatHeader.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { ArrowLeft, MoreVertical, Users, Info, LogOut, Trash2, Settings } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Chat } from '../types';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { wsClient } from '../api/ws';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chat: Chat;
|
||||||
|
onBack: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onShowInfo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatHeader({ chat, onBack, onRefresh, onShowInfo }: Props) {
|
||||||
|
const { user, typingUsers, onlineUsers, setActiveChat, setChats, chats } = useStore();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const typing = typingUsers[chat.id] || [];
|
||||||
|
const isPrivate = chat.type === 'private';
|
||||||
|
const isOnline = isPrivate && chat.privateUserId && onlineUsers.has(chat.privateUserId);
|
||||||
|
|
||||||
|
let subtitle = '';
|
||||||
|
if (typing.length > 0) {
|
||||||
|
subtitle = 'печатает...';
|
||||||
|
} else if (isPrivate) {
|
||||||
|
subtitle = isOnline ? 'в сети' : 'не в сети';
|
||||||
|
} else {
|
||||||
|
subtitle = `${chat.memberCount} участников`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || user?.isAdmin;
|
||||||
|
|
||||||
|
async function handleLeave() {
|
||||||
|
if (!confirm('Выйти из чата?')) return;
|
||||||
|
await api.delete(`/api/chats/${chat.id}/leave`);
|
||||||
|
setActiveChat(null);
|
||||||
|
setChats(chats.filter(c => c.id !== chat.id));
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm('Удалить чат? Это действие нельзя отменить.')) return;
|
||||||
|
await api.delete(`/api/chats/${chat.id}`);
|
||||||
|
setActiveChat(null);
|
||||||
|
setChats(chats.filter(c => c.id !== chat.id));
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = chat.type === 'channel' ? 'Канал' : chat.type === 'group' ? 'Группа' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border-b border-gray-100 px-4 py-3 flex items-center gap-3 shadow-sm relative z-10">
|
||||||
|
{/* Back button (mobile) */}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="md:hidden p-1 -ml-1 rounded-lg hover:bg-gray-100 text-gray-600"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="cursor-pointer" onClick={onShowInfo}>
|
||||||
|
<Avatar
|
||||||
|
name={chat.title}
|
||||||
|
color={chat.avatarColor}
|
||||||
|
online={isPrivate ? isOnline : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title & status */}
|
||||||
|
<div className="flex-1 min-w-0 cursor-pointer" onClick={onShowInfo}>
|
||||||
|
<div className="font-semibold text-gray-900 text-sm truncate">
|
||||||
|
{typeLabel && <span className="text-gray-400 font-normal text-xs mr-1">{typeLabel}</span>}
|
||||||
|
{chat.title}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs truncate ${typing.length > 0 ? 'text-blue-500' : isOnline ? 'text-green-500' : 'text-gray-400'}`}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-10 bg-white rounded-xl shadow-xl border border-gray-100 py-1 w-48 z-20">
|
||||||
|
<button
|
||||||
|
onClick={() => { onShowInfo(); setMenuOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" /> Информация
|
||||||
|
</button>
|
||||||
|
{chat.type !== 'private' && isOwnerOrAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onShowInfo(); setMenuOpen(false); }}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" /> Управление
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{chat.type !== 'private' && (
|
||||||
|
<button
|
||||||
|
onClick={handleLeave}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" /> Выйти
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Удалить чат
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
frontend/src/components/ChatInfoPanel.tsx
Normal file
186
frontend/src/components/ChatInfoPanel.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { X, UserPlus, Crown, Shield, User, Trash2, Ban } from 'lucide-react';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Chat, ChatMember } from '../types';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chat: Chat;
|
||||||
|
onClose: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) {
|
||||||
|
const { user: me, onlineUsers } = useStore();
|
||||||
|
const [members, setMembers] = useState<ChatMember[]>(chat.members || []);
|
||||||
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||||
|
const [addMode, setAddMode] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [title, setTitle] = useState(chat.title || '');
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || me?.isAdmin;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (addMode) {
|
||||||
|
api.get('/api/users').then(r => setAllUsers(r.data));
|
||||||
|
}
|
||||||
|
}, [addMode]);
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
await api.put(`/api/chats/${chat.id}`, { title });
|
||||||
|
setEditing(false);
|
||||||
|
onRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMember(userId: string) {
|
||||||
|
await api.post(`/api/chats/${chat.id}/members`, { memberId: userId });
|
||||||
|
const { data } = await api.get(`/api/chats/${chat.id}`);
|
||||||
|
setMembers(data.members);
|
||||||
|
setAddMode(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(userId: string) {
|
||||||
|
if (!confirm('Удалить участника?')) return;
|
||||||
|
await api.delete(`/api/chats/${chat.id}/members/${userId}`);
|
||||||
|
setMembers(m => m.filter(x => x.id !== userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRole(m: ChatMember) {
|
||||||
|
const newRole = m.role === 'admin' ? 'member' : 'admin';
|
||||||
|
await api.put(`/api/chats/${chat.id}/members/${m.id}`, { role: newRole });
|
||||||
|
setMembers(mems => mems.map(x => x.id === m.id ? { ...x, role: newRole } : x));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSend(m: ChatMember) {
|
||||||
|
await api.put(`/api/chats/${chat.id}/members/${m.id}`, { canSendMessages: !m.canSendMessages });
|
||||||
|
setMembers(mems => mems.map(x => x.id === m.id ? { ...x, canSendMessages: !x.canSendMessages } : x));
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberIds = new Set(members.map(m => m.id));
|
||||||
|
const filtered = allUsers.filter(u =>
|
||||||
|
!memberIds.has(u.id) &&
|
||||||
|
(u.displayName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.username.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleIcon = (role: string) =>
|
||||||
|
role === 'owner' ? <Crown className="w-3 h-3 text-yellow-500" />
|
||||||
|
: role === 'admin' ? <Shield className="w-3 h-3 text-blue-500" />
|
||||||
|
: <User className="w-3 h-3 text-gray-400" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-72 bg-white border-l border-gray-100 flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm">
|
||||||
|
{chat.type === 'channel' ? 'Канал' : chat.type === 'group' ? 'Группа' : 'Контакт'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{/* Avatar & Title */}
|
||||||
|
<div className="flex flex-col items-center py-6 px-4">
|
||||||
|
<Avatar name={chat.title} color={chat.avatarColor} size="xl" />
|
||||||
|
<div className="mt-3 w-full">
|
||||||
|
{editing && chat.type !== 'private' ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
className="flex-1 px-2 py-1 border rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<button onClick={saveTitle} className="px-3 py-1 bg-blue-500 text-white text-sm rounded-lg">OK</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="px-3 py-1 bg-gray-100 text-sm rounded-lg">✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="font-semibold text-gray-900 cursor-pointer hover:text-blue-500"
|
||||||
|
onClick={() => isOwnerOrAdmin && chat.type !== 'private' && setEditing(true)}
|
||||||
|
>
|
||||||
|
{chat.title}
|
||||||
|
</div>
|
||||||
|
{chat.description && <p className="text-xs text-gray-500 mt-1">{chat.description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members */}
|
||||||
|
{chat.type !== 'private' && (
|
||||||
|
<div className="px-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Участники ({members.length})
|
||||||
|
</span>
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<button onClick={() => setAddMode(!addMode)} className="p-1 rounded hover:bg-gray-100 text-blue-500">
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addMode && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск пользователей..."
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-xl text-sm mb-2"
|
||||||
|
/>
|
||||||
|
<div className="max-h-40 overflow-y-auto space-y-1">
|
||||||
|
{filtered.map(u => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => addMember(u.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-blue-50 text-sm text-left"
|
||||||
|
>
|
||||||
|
<Avatar name={u.displayName} color={u.avatarColor} size="sm" />
|
||||||
|
<span>{u.displayName}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && <p className="text-xs text-gray-400 text-center py-2">Никого не найдено</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1 pb-4">
|
||||||
|
{members.map(m => (
|
||||||
|
<div key={m.id} className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-gray-50 group">
|
||||||
|
<Avatar name={m.displayName} color={m.avatarColor} size="sm" online={onlineUsers.has(m.id)} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1 text-sm font-medium text-gray-900">
|
||||||
|
{m.displayName}
|
||||||
|
{roleIcon(m.role)}
|
||||||
|
{!m.canSendMessages && <Ban className="w-3 h-3 text-red-400" />}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">@{m.username}</div>
|
||||||
|
</div>
|
||||||
|
{isOwnerOrAdmin && m.id !== me?.id && m.role !== 'owner' && (
|
||||||
|
<div className="hidden group-hover:flex gap-1">
|
||||||
|
<button onClick={() => toggleRole(m)} title="Изменить роль" className="p-1 hover:bg-gray-200 rounded">
|
||||||
|
<Shield className="w-3 h-3 text-blue-400" />
|
||||||
|
</button>
|
||||||
|
{chat.type === 'channel' && (
|
||||||
|
<button onClick={() => toggleSend(m)} title="Запрет сообщений" className="p-1 hover:bg-gray-200 rounded">
|
||||||
|
<Ban className="w-3 h-3 text-orange-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => removeMember(m.id)} title="Удалить" className="p-1 hover:bg-gray-200 rounded">
|
||||||
|
<Trash2 className="w-3 h-3 text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/components/ChatListItem.tsx
Normal file
53
frontend/src/components/ChatListItem.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
import { Chat } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chat: Chat;
|
||||||
|
active: boolean;
|
||||||
|
online?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChatListItem({ chat, active, online, onClick }: Props) {
|
||||||
|
const time = chat.lastMessageAt
|
||||||
|
? formatDistanceToNow(new Date(chat.lastMessageAt), { addSuffix: false, locale: ru })
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const icon = chat.type === 'channel' ? '📢' : chat.type === 'group' ? '👥' : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors ${
|
||||||
|
active ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={chat.title}
|
||||||
|
color={chat.avatarColor}
|
||||||
|
online={chat.type === 'private' ? online : undefined}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-900 text-sm truncate">
|
||||||
|
{icon && <span className="mr-1 text-xs">{icon}</span>}
|
||||||
|
{chat.title}
|
||||||
|
</span>
|
||||||
|
{time && <span className="text-xs text-gray-400 flex-shrink-0 ml-1">{time}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{chat.lastMessage || (chat.type === 'channel' ? 'Канал' : 'Нет сообщений')}
|
||||||
|
</p>
|
||||||
|
{chat.unreadCount > 0 && (
|
||||||
|
<span className="bg-blue-500 text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1 flex-shrink-0 ml-1">
|
||||||
|
{chat.unreadCount > 99 ? '99+' : chat.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
frontend/src/components/MessageInput.tsx
Normal file
199
frontend/src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Paperclip, X, Smile } from 'lucide-react';
|
||||||
|
import { wsClient } from '../api/ws';
|
||||||
|
import api from '../api/client';
|
||||||
|
import { Message } from '../types';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId: string;
|
||||||
|
replyTo: Message | null;
|
||||||
|
editMsg: Message | null;
|
||||||
|
onCancelReply: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onEditDone: (msg: Message) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMOJIS = ['😀','😂','😍','🥰','😎','👍','❤️','🔥','✅','👋','🙏','💪','🎉','💯','😊','🤔','😅','🙌','💬','📌'];
|
||||||
|
|
||||||
|
export default function MessageInput({ chatId, replyTo, editMsg, onCancelReply, onCancelEdit, onEditDone }: Props) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [showEmoji, setShowEmoji] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const typingTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const { addMessage, user } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMsg) {
|
||||||
|
setText(editMsg.content);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [editMsg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (replyTo) textareaRef.current?.focus();
|
||||||
|
}, [replyTo]);
|
||||||
|
|
||||||
|
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||||
|
setText(e.target.value);
|
||||||
|
autoResize();
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
wsClient.send('typing', { chatId, typing: true });
|
||||||
|
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
||||||
|
typingTimeout.current = setTimeout(() => {
|
||||||
|
wsClient.send('typing', { chatId, typing: false });
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize() {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancelReply();
|
||||||
|
onCancelEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const content = text.trim();
|
||||||
|
if (!content || sending) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
setText('');
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||||
|
wsClient.send('typing', { chatId, typing: false });
|
||||||
|
|
||||||
|
if (editMsg) {
|
||||||
|
wsClient.send('edit_message', { messageId: editMsg.id, content });
|
||||||
|
onEditDone({ ...editMsg, content, isEdited: true });
|
||||||
|
} else {
|
||||||
|
wsClient.send('send_message', {
|
||||||
|
chatId,
|
||||||
|
content,
|
||||||
|
type: 'text',
|
||||||
|
replyToId: replyTo?.id || null,
|
||||||
|
});
|
||||||
|
onCancelReply();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = '';
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/api/messages/chat/${chatId}/upload`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
addMessage(data);
|
||||||
|
} catch {
|
||||||
|
alert('Ошибка загрузки файла');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEmoji(emoji: string) {
|
||||||
|
setText(t => t + emoji);
|
||||||
|
setShowEmoji(false);
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = !!editMsg;
|
||||||
|
const placeholder = isEdit ? 'Редактирование...' : replyTo ? 'Ответить...' : 'Сообщение...';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-100 bg-white px-4 pb-safe pt-2">
|
||||||
|
{/* Reply/Edit preview */}
|
||||||
|
{(replyTo || editMsg) && (
|
||||||
|
<div className="flex items-center gap-2 mb-2 pl-3 border-l-2 border-blue-400 bg-blue-50 rounded-r-lg py-1.5 pr-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-blue-600">
|
||||||
|
{isEdit ? 'Редактирование' : `Ответ: ${replyTo?.sender?.displayName}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 truncate">
|
||||||
|
{isEdit ? editMsg?.content : replyTo?.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={isEdit ? onCancelEdit : onCancelReply}
|
||||||
|
className="p-0.5 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
{/* Emoji picker */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEmoji(!showEmoji)}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Smile className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
{showEmoji && (
|
||||||
|
<div className="absolute bottom-10 left-0 bg-white rounded-2xl shadow-xl p-3 grid grid-cols-5 gap-1 z-10 border border-gray-100">
|
||||||
|
{EMOJIS.map(e => (
|
||||||
|
<button key={e} onClick={() => insertEmoji(e)} className="text-xl hover:bg-gray-100 rounded-lg p-1">
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-gray-100 rounded-2xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 max-h-[120px] leading-relaxed"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File upload */}
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 transition-colors rounded-lg hover:bg-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" className="hidden" onChange={handleFile} />
|
||||||
|
|
||||||
|
{/* Send */}
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!text.trim() || sending}
|
||||||
|
className="p-2.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
frontend/src/components/MessageItem.tsx
Normal file
144
frontend/src/components/MessageItem.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
import { Check, CheckCheck, Pencil, Trash2, Reply, Download } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Message } from '../types';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: Message;
|
||||||
|
isMine: boolean;
|
||||||
|
showAvatar: boolean;
|
||||||
|
isGroup: boolean;
|
||||||
|
onReply: (msg: Message) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onEdit: (msg: Message) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageItem({ message, isMine, showAvatar, isGroup, onReply, onDelete, onEdit }: Props) {
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
if (message.isDeleted) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-end gap-2 mb-1 ${isMine ? 'flex-row-reverse' : ''}`}>
|
||||||
|
{showAvatar && !isMine ? <div className="w-8" /> : null}
|
||||||
|
<div className={`px-3 py-2 rounded-2xl text-sm italic text-gray-400 bg-gray-100 max-w-xs`}>
|
||||||
|
Сообщение удалено
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = format(new Date(message.createdAt), 'HH:mm', { locale: ru });
|
||||||
|
|
||||||
|
const imageAttachments = message.attachments.filter(a => a.mimeType.startsWith('image/'));
|
||||||
|
const fileAttachments = message.attachments.filter(a => !a.mimeType.startsWith('image/'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-end gap-2 mb-1 group ${isMine ? 'flex-row-reverse' : ''}`}
|
||||||
|
onMouseLeave={() => setShowMenu(false)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
{!isMine && isGroup ? (
|
||||||
|
showAvatar && message.sender ? (
|
||||||
|
<Avatar name={message.sender.displayName} color={message.sender.avatarColor} size="sm" />
|
||||||
|
) : <div className="w-8 flex-shrink-0" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Bubble */}
|
||||||
|
<div className={`relative max-w-[70%] lg:max-w-[60%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||||
|
{/* Sender name in group */}
|
||||||
|
{!isMine && isGroup && showAvatar && message.sender && (
|
||||||
|
<span className="text-xs font-medium mb-0.5 px-1" style={{ color: message.sender.avatarColor }}>
|
||||||
|
{message.sender.displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reply preview */}
|
||||||
|
{message.replyTo && (
|
||||||
|
<div className={`mb-1 px-3 py-1.5 rounded-xl text-xs border-l-2 border-blue-400 bg-blue-50 max-w-full`}>
|
||||||
|
<div className="font-medium text-blue-600">{message.replyTo.senderName}</div>
|
||||||
|
<div className="text-gray-600 truncate">{message.replyTo.content}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{imageAttachments.length > 0 && (
|
||||||
|
<div className="mb-1 rounded-xl overflow-hidden">
|
||||||
|
{imageAttachments.map(a => (
|
||||||
|
<a key={a.id || a.filename} href={a.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={a.url} alt={a.originalName} className="max-w-full max-h-64 object-cover rounded-xl" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File attachments */}
|
||||||
|
{fileAttachments.map(a => (
|
||||||
|
<a
|
||||||
|
key={a.id || a.filename}
|
||||||
|
href={a.url}
|
||||||
|
download={a.originalName}
|
||||||
|
className={`flex items-center gap-2 mb-1 px-3 py-2 rounded-xl text-sm ${isMine ? 'bg-blue-500 text-white' : 'bg-white border border-gray-200 text-gray-800'}`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate max-w-[200px]">{a.originalName}</span>
|
||||||
|
<span className="text-xs opacity-70">{(a.size / 1024).toFixed(0)}кб</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Text bubble */}
|
||||||
|
{(message.content && message.type !== 'system') && (
|
||||||
|
<div
|
||||||
|
className={`msg-enter px-3 py-2 rounded-2xl text-sm leading-relaxed ${
|
||||||
|
isMine
|
||||||
|
? 'bg-blue-500 text-white rounded-br-sm'
|
||||||
|
: 'bg-white text-gray-900 shadow-sm rounded-bl-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="break-words whitespace-pre-wrap">{message.content}</span>
|
||||||
|
<span className={`text-xs ml-2 float-right mt-1 flex items-center gap-0.5 ${isMine ? 'text-blue-200' : 'text-gray-400'}`}>
|
||||||
|
{message.isEdited && <span className="mr-1">ред.</span>}
|
||||||
|
{time}
|
||||||
|
{isMine && <CheckCheck className="w-3 h-3 ml-0.5" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.type === 'system' && (
|
||||||
|
<div className="text-xs text-center text-gray-400 italic py-1">{message.content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className={`opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 ${isMine ? 'flex-row-reverse' : ''}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => onReply(message)}
|
||||||
|
className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Ответить"
|
||||||
|
>
|
||||||
|
<Reply className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{isMine && message.type === 'text' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(message)}
|
||||||
|
className="p-1 rounded-lg hover:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isMine && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(message.id)}
|
||||||
|
className="p-1 rounded-lg hover:bg-red-100 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
frontend/src/components/MessageList.tsx
Normal file
180
frontend/src/components/MessageList.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { format, isToday, isYesterday, isSameDay } from 'date-fns';
|
||||||
|
import { ru } from 'date-fns/locale';
|
||||||
|
import { Message } from '../types';
|
||||||
|
import MessageItem from './MessageItem';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import api from '../api/client';
|
||||||
|
import { wsClient } from '../api/ws';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId: string;
|
||||||
|
isGroup: boolean;
|
||||||
|
canSend: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateSeparator({ date }: { date: Date }) {
|
||||||
|
const label = isToday(date) ? 'Сегодня'
|
||||||
|
: isYesterday(date) ? 'Вчера'
|
||||||
|
: format(date, 'd MMMM yyyy', { locale: ru });
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center my-3">
|
||||||
|
<span className="bg-white text-gray-500 text-xs px-3 py-1 rounded-full shadow-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageList({ chatId, isGroup, canSend }: Props) {
|
||||||
|
const { messages, setMessages, prependMessages, user, removeMessage, updateMessage } = useStore();
|
||||||
|
const msgs = messages[chatId] || [];
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [replyTo, setReplyTo] = useState<Message | null>(null);
|
||||||
|
const [editMsg, setEditMsg] = useState<Message | null>(null);
|
||||||
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
|
const isAtBottom = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMessages();
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAtBottom.current) scrollToBottom();
|
||||||
|
}, [msgs.length]);
|
||||||
|
|
||||||
|
async function loadMessages() {
|
||||||
|
setLoading(true);
|
||||||
|
setHasMore(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/messages/chat/${chatId}`);
|
||||||
|
setMessages(chatId, data);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setTimeout(scrollToBottom, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loading || !hasMore || msgs.length === 0) return;
|
||||||
|
const firstId = msgs[0]?.id;
|
||||||
|
if (!firstId) return;
|
||||||
|
setLoading(true);
|
||||||
|
const prevHeight = containerRef.current?.scrollHeight || 0;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/messages/chat/${chatId}?before=${firstId}`);
|
||||||
|
if (data.length === 0) { setHasMore(false); return; }
|
||||||
|
prependMessages(chatId, data);
|
||||||
|
// Maintain scroll position
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||||||
|
}, 10);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
setShowScrollBtn(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||||
|
isAtBottom.current = atBottom;
|
||||||
|
setShowScrollBtn(!atBottom);
|
||||||
|
if (el.scrollTop < 100) loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
wsClient.send('delete_message', { messageId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(msg: Message) {
|
||||||
|
setEditMsg(msg);
|
||||||
|
setReplyTo(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReply(msg: Message) {
|
||||||
|
setReplyTo(msg);
|
||||||
|
setEditMsg(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group messages by date and determine showAvatar
|
||||||
|
const rendered: JSX.Element[] = [];
|
||||||
|
let lastDate: Date | null = null;
|
||||||
|
|
||||||
|
msgs.forEach((msg, i) => {
|
||||||
|
const date = new Date(msg.createdAt);
|
||||||
|
if (!lastDate || !isSameDay(date, lastDate)) {
|
||||||
|
rendered.push(<DateSeparator key={`sep-${msg.id}`} date={date} />);
|
||||||
|
lastDate = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = msgs[i + 1];
|
||||||
|
const showAvatar = !next || next.sender?.id !== msg.sender?.id ||
|
||||||
|
new Date(next.createdAt).getTime() - date.getTime() > 60000;
|
||||||
|
|
||||||
|
rendered.push(
|
||||||
|
<MessageItem
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
isMine={msg.sender?.id === user?.id}
|
||||||
|
showAvatar={showAvatar}
|
||||||
|
isGroup={isGroup}
|
||||||
|
onReply={handleReply}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full relative">
|
||||||
|
{/* Messages */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="flex-1 overflow-y-auto px-4 py-3 space-y-0.5"
|
||||||
|
>
|
||||||
|
{loading && msgs.length === 0 && (
|
||||||
|
<div className="text-center text-gray-400 text-sm py-8">Загрузка...</div>
|
||||||
|
)}
|
||||||
|
{!loading && msgs.length === 0 && (
|
||||||
|
<div className="text-center text-gray-400 text-sm py-8">Нет сообщений. Начните общение!</div>
|
||||||
|
)}
|
||||||
|
{rendered}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll to bottom button */}
|
||||||
|
{showScrollBtn && (
|
||||||
|
<button
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
className="absolute bottom-20 right-4 bg-white shadow-lg rounded-full p-2 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input area */}
|
||||||
|
{canSend && (
|
||||||
|
<MessageInput
|
||||||
|
chatId={chatId}
|
||||||
|
replyTo={replyTo}
|
||||||
|
editMsg={editMsg}
|
||||||
|
onCancelReply={() => setReplyTo(null)}
|
||||||
|
onCancelEdit={() => setEditMsg(null)}
|
||||||
|
onEditDone={(msg) => { updateMessage(msg); setEditMsg(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageInput is imported inside
|
||||||
|
import MessageInput from './MessageInput';
|
||||||
227
frontend/src/components/NewChatModal.tsx
Normal file
227
frontend/src/components/NewChatModal.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Search, MessageCircle, Users, Radio } from 'lucide-react';
|
||||||
|
import api from '../api/client';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import Avatar from './Avatar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
onOpen: (chatId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = 'type' | 'user' | 'group';
|
||||||
|
|
||||||
|
export default function NewChatModal({ onClose, onOpen }: Props) {
|
||||||
|
const [step, setStep] = useState<Step>('type');
|
||||||
|
const [chatType, setChatType] = useState<'private' | 'group' | 'channel'>('private');
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [groupTitle, setGroupTitle] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { setChats, chats } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/api/users').then(r => setUsers(r.data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = users.filter(u =>
|
||||||
|
u.displayName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.username.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleSelect(id: string) {
|
||||||
|
setSelected(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPrivate(userId: string) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(`/api/chats/private/${userId}`);
|
||||||
|
const { data: chat } = await api.get(`/api/chats/${data.id}`);
|
||||||
|
// Refresh chats
|
||||||
|
const { data: allChats } = await api.get('/api/chats');
|
||||||
|
setChats(allChats);
|
||||||
|
onOpen(data.id);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup() {
|
||||||
|
if (!groupTitle.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/chats', {
|
||||||
|
type: chatType,
|
||||||
|
title: groupTitle,
|
||||||
|
memberIds: selected,
|
||||||
|
});
|
||||||
|
const { data: allChats } = await api.get('/api/chats');
|
||||||
|
setChats(allChats);
|
||||||
|
onOpen(data.id);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
||||||
|
<div className="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[85vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="font-semibold text-gray-900">
|
||||||
|
{step === 'type' ? 'Новый чат' : step === 'user' ? 'Выбор пользователя' : 'Создание ' + (chatType === 'channel' ? 'канала' : 'группы')}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Type selection */}
|
||||||
|
{step === 'type' && (
|
||||||
|
<div className="p-5 space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setChatType('private'); setStep('user'); }}
|
||||||
|
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-blue-50 border border-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<MessageCircle className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium text-gray-900">Личный чат</div>
|
||||||
|
<div className="text-sm text-gray-500">Переписка с одним пользователем</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setChatType('group'); setStep('group'); }}
|
||||||
|
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-purple-50 border border-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||||
|
<Users className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium text-gray-900">Группа</div>
|
||||||
|
<div className="text-sm text-gray-500">Общение нескольких участников</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setChatType('channel'); setStep('group'); }}
|
||||||
|
className="w-full flex items-center gap-4 p-4 rounded-xl hover:bg-orange-50 border border-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||||
|
<Radio className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium text-gray-900">Канал</div>
|
||||||
|
<div className="text-sm text-gray-500">Публикации от администраторов</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Select user (private) */}
|
||||||
|
{step === 'user' && (
|
||||||
|
<>
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
|
||||||
|
<Search className="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск..." className="flex-1 bg-transparent text-sm outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||||
|
{filtered.map(u => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => createPrivate(u.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-gray-50 text-left"
|
||||||
|
>
|
||||||
|
<Avatar name={u.displayName} color={u.avatarColor} online={u.online} />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm text-gray-900">{u.displayName}</div>
|
||||||
|
<div className="text-xs text-gray-400">@{u.username}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Create group/channel */}
|
||||||
|
{step === 'group' && (
|
||||||
|
<>
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100 space-y-3">
|
||||||
|
<input
|
||||||
|
value={groupTitle}
|
||||||
|
onChange={e => setGroupTitle(e.target.value)}
|
||||||
|
placeholder={chatType === 'channel' ? 'Название канала' : 'Название группы'}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
|
||||||
|
<Search className="w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Добавить участников..." className="flex-1 bg-transparent text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{selected.map(id => {
|
||||||
|
const u = users.find(x => x.id === id);
|
||||||
|
return u ? (
|
||||||
|
<span key={id} onClick={() => toggleSelect(id)}
|
||||||
|
className="flex items-center gap-1 bg-blue-100 text-blue-700 text-xs px-2 py-1 rounded-full cursor-pointer">
|
||||||
|
{u.displayName} ×
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||||
|
{filtered.map(u => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => toggleSelect(u.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-colors ${
|
||||||
|
selected.includes(u.id) ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar name={u.displayName} color={u.avatarColor} />
|
||||||
|
{selected.includes(u.id) && (
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-white text-[10px]">✓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm text-gray-900">{u.displayName}</div>
|
||||||
|
<div className="text-xs text-gray-400">@{u.username}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={createGroup}
|
||||||
|
disabled={!groupTitle.trim() || loading}
|
||||||
|
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-40 text-white py-3 rounded-xl font-medium text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Создание...' : `Создать ${chatType === 'channel' ? 'канал' : 'группу'}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/admin/AdminPanel.tsx
Normal file
197
frontend/src/components/admin/AdminPanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Plus, Pencil, Trash2, Users, MessageSquare, BarChart3, Check, RefreshCw } from 'lucide-react';
|
||||||
|
import api from '../../api/client';
|
||||||
|
import Avatar from '../Avatar';
|
||||||
|
|
||||||
|
interface Props { onClose: () => void; }
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string; username: string; displayName: string; avatarColor: string;
|
||||||
|
isAdmin: boolean; isActive: boolean; lastSeen: string; createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPanel({ onClose }: Props) {
|
||||||
|
const [tab, setTab] = useState<'users' | 'stats'>('users');
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [stats, setStats] = useState<any>(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [editUser, setEditUser] = useState<UserRow | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => { loadUsers(); loadStats(); }, []);
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const { data } = await api.get('/api/admin/users');
|
||||||
|
setUsers(data);
|
||||||
|
}
|
||||||
|
async function loadStats() {
|
||||||
|
const { data } = await api.get('/api/admin/stats');
|
||||||
|
setStats(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id: string) {
|
||||||
|
if (!confirm('Удалить пользователя?')) return;
|
||||||
|
await api.delete(`/api/admin/users/${id}`);
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(u: UserRow) {
|
||||||
|
await api.put(`/api/admin/users/${u.id}`, { isActive: !u.isActive });
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
||||||
|
<div className="bg-white rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="font-semibold text-gray-900">Панель администратора</h2>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 text-gray-400">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-100 px-4">
|
||||||
|
{[['users','Пользователи'],['stats','Статистика']].map(([k,v]) => (
|
||||||
|
<button key={k} onClick={() => setTab(k as any)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
tab === k ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}>{v}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{tab === 'users' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-gray-500">{users.length} пользователей</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCreate(true); setEditUser(null); }}
|
||||||
|
className="flex items-center gap-2 bg-blue-500 text-white px-4 py-2 rounded-xl text-sm hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(showCreate || editUser) && (
|
||||||
|
<UserForm
|
||||||
|
user={editUser}
|
||||||
|
onSave={() => { setShowCreate(false); setEditUser(null); loadUsers(); }}
|
||||||
|
onCancel={() => { setShowCreate(false); setEditUser(null); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.id} className={`flex items-center gap-3 p-3 rounded-xl border ${u.isActive ? 'border-gray-100 bg-white' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
|
||||||
|
<Avatar name={u.displayName} color={u.avatarColor} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-gray-900">{u.displayName}</span>
|
||||||
|
{u.isAdmin && <span className="text-xs bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded-full">admin</span>}
|
||||||
|
{!u.isActive && <span className="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full">заблокирован</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">@{u.username}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => toggleActive(u)} title={u.isActive ? 'Заблокировать' : 'Активировать'}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
|
||||||
|
{u.isActive ? <Check className="w-4 h-4 text-green-500" /> : <RefreshCw className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditUser(u); setShowCreate(false); }}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400">
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteUser(u.id)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'stats' && stats && (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Пользователи', value: stats.users, icon: Users, color: 'blue' },
|
||||||
|
{ label: 'Чаты', value: stats.chats, icon: MessageSquare, color: 'green' },
|
||||||
|
{ label: 'Сообщения', value: stats.messages, icon: BarChart3, color: 'purple' },
|
||||||
|
].map(({ label, value, icon: Icon, color }) => (
|
||||||
|
<div key={label} className={`bg-${color}-50 rounded-2xl p-5 text-center`}>
|
||||||
|
<Icon className={`w-8 h-8 text-${color}-500 mx-auto mb-2`} />
|
||||||
|
<div className={`text-3xl font-bold text-${color}-700`}>{value}</div>
|
||||||
|
<div className={`text-sm text-${color}-600 mt-1`}>{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserForm({ user, onSave, onCancel }: { user: UserRow | null; onSave: () => void; onCancel: () => void }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
username: user?.username || '',
|
||||||
|
displayName: user?.displayName || '',
|
||||||
|
password: '',
|
||||||
|
isAdmin: user?.isAdmin || false,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (user) {
|
||||||
|
await api.put(`/api/admin/users/${user.id}`, {
|
||||||
|
displayName: form.displayName,
|
||||||
|
isAdmin: form.isAdmin,
|
||||||
|
password: form.password || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api.post('/api/admin/users', form);
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-blue-50 rounded-xl p-4 mb-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{!user && (
|
||||||
|
<input value={form.username} onChange={e => setForm({...form, username: e.target.value})}
|
||||||
|
placeholder="Логин" required className="px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
)}
|
||||||
|
<input value={form.displayName} onChange={e => setForm({...form, displayName: e.target.value})}
|
||||||
|
placeholder="Имя" required className="px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
<input value={form.password} onChange={e => setForm({...form, password: e.target.value})}
|
||||||
|
type="password" placeholder={user ? 'Новый пароль (необязательно)' : 'Пароль'}
|
||||||
|
required={!user} className="px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={form.isAdmin} onChange={e => setForm({...form, isAdmin: e.target.checked})} />
|
||||||
|
Администратор
|
||||||
|
</label>
|
||||||
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 disabled:opacity-50">
|
||||||
|
{loading ? '...' : user ? 'Сохранить' : 'Создать'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} className="px-4 py-2 bg-gray-100 text-sm rounded-lg hover:bg-gray-200">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/hooks/usePushNotifications.ts
Normal file
46
frontend/src/hooks/usePushNotifications.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import api from '../api/client';
|
||||||
|
|
||||||
|
function urlBase64ToUint8Array(base64String: string) {
|
||||||
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
return Uint8Array.from([...rawData].map(c => c.charCodeAt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePushNotifications(enabled: boolean) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (!('Notification' in window) || !('serviceWorker' in navigator)) return;
|
||||||
|
|
||||||
|
async function subscribe() {
|
||||||
|
try {
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') return;
|
||||||
|
|
||||||
|
const { data } = await api.get('/api/push/vapid-public-key');
|
||||||
|
if (!data.key) return;
|
||||||
|
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
let sub = await reg.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(data.key),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = sub.toJSON();
|
||||||
|
await api.post('/api/push/subscribe', {
|
||||||
|
endpoint: json.endpoint,
|
||||||
|
keys: json.keys,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Push subscription failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe();
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
37
frontend/src/index.css
Normal file
37
frontend/src/index.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.msg-enter { animation: fadeIn 0.15s ease-out; }
|
||||||
|
|
||||||
|
/* Safe areas for mobile */
|
||||||
|
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
|
||||||
|
.pt-safe { padding-top: env(safe-area-inset-top); }
|
||||||
17
frontend/src/main.tsx
Normal file
17
frontend/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
// Register service worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
94
frontend/src/pages/Login.tsx
Normal file
94
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../api/client';
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLogin: (token: string, user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage({ onLogin }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/api/auth/login', { username, password });
|
||||||
|
onLogin(data.token, data.user);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Ошибка входа');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl w-full max-w-sm p-8">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-blue-500 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<svg className="w-9 h-9 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">JaniChat</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">Войдите в аккаунт</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Логин</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
placeholder="Введите логин"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-600 text-sm px-4 py-3 rounded-xl">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition"
|
||||||
|
>
|
||||||
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-gray-400 mt-6">
|
||||||
|
Доступ только для зарегистрированных пользователей
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
frontend/src/pages/MainLayout.tsx
Normal file
206
frontend/src/pages/MainLayout.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Search, Edit, LogOut, Settings, Shield, Wifi, WifiOff } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { wsClient } from '../api/ws';
|
||||||
|
import api from '../api/client';
|
||||||
|
import Avatar from '../components/Avatar';
|
||||||
|
import ChatListItem from '../components/ChatListItem';
|
||||||
|
import MessageList from '../components/MessageList';
|
||||||
|
import ChatHeader from '../components/ChatHeader';
|
||||||
|
import ChatInfoPanel from '../components/ChatInfoPanel';
|
||||||
|
import NewChatModal from '../components/NewChatModal';
|
||||||
|
import AdminPanel from '../components/admin/AdminPanel';
|
||||||
|
import { usePushNotifications } from '../hooks/usePushNotifications';
|
||||||
|
import { Chat } from '../types';
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
const { user, chats, activeChat, setActiveChat, connected, logout, onlineUsers, updateChat } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
|
const [mobileChatOpen, setMobileChatOpen] = useState(false);
|
||||||
|
const [fullChat, setFullChat] = useState<Chat | null>(null);
|
||||||
|
|
||||||
|
usePushNotifications(!!user);
|
||||||
|
|
||||||
|
// Handle URL param ?chat=xxx
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const chatId = params.get('chat');
|
||||||
|
if (chatId) {
|
||||||
|
const chat = chats.find(c => c.id === chatId);
|
||||||
|
if (chat) openChat(chat);
|
||||||
|
}
|
||||||
|
}, [chats]);
|
||||||
|
|
||||||
|
// Handle SW messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('serviceWorker' in navigator)) return;
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
if (event.data?.type === 'open_chat') {
|
||||||
|
const chat = chats.find(c => c.id === event.data.chatId);
|
||||||
|
if (chat) openChat(chat);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
navigator.serviceWorker.addEventListener('message', handler);
|
||||||
|
return () => navigator.serviceWorker.removeEventListener('message', handler);
|
||||||
|
}, [chats]);
|
||||||
|
|
||||||
|
async function openChat(chat: Chat) {
|
||||||
|
// Load full chat info
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/api/chats/${chat.id}`);
|
||||||
|
const merged = { ...chat, ...data };
|
||||||
|
setActiveChat(merged);
|
||||||
|
setFullChat(merged);
|
||||||
|
updateChat({ id: chat.id, unreadCount: 0 });
|
||||||
|
wsClient.send('read_messages', { chatId: chat.id });
|
||||||
|
} catch {
|
||||||
|
setActiveChat(chat);
|
||||||
|
setFullChat(chat);
|
||||||
|
}
|
||||||
|
setMobileChatOpen(true);
|
||||||
|
setShowInfo(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
wsClient.disconnect();
|
||||||
|
logout();
|
||||||
|
navigate('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = chats.filter(c =>
|
||||||
|
c.title?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSend = (() => {
|
||||||
|
if (!activeChat) return false;
|
||||||
|
if (activeChat.type === 'private') return true;
|
||||||
|
if (activeChat.type === 'group') return activeChat.canSendMessages !== false;
|
||||||
|
if (activeChat.type === 'channel') return activeChat.myRole === 'owner' || activeChat.myRole === 'admin' || !!user?.isAdmin;
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full bg-gray-100">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className={`w-full md:w-80 lg:w-96 bg-white flex flex-col border-r border-gray-100 ${mobileChatOpen ? 'hidden md:flex' : 'flex'}`}>
|
||||||
|
{/* Sidebar Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Avatar name={user?.displayName || ''} color={user?.avatarColor || '#3b82f6'} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-gray-900 text-sm truncate">{user?.displayName}</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||||
|
{connected
|
||||||
|
? <><Wifi className="w-3 h-3 text-green-400" /> <span>Online</span></>
|
||||||
|
: <><WifiOff className="w-3 h-3 text-red-400" /> <span>Offline</span></>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<button onClick={() => setShowAdmin(true)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Панель администратора">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setShowNew(true)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600" title="Новый чат">
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleLogout}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-red-500" title="Выйти">
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex items-center gap-2 bg-gray-100 rounded-xl px-3 py-2">
|
||||||
|
<Search className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||||
|
<input
|
||||||
|
value={search} onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Поиск чатов..."
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none text-gray-700 placeholder-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat List */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center text-gray-400 text-sm py-8">
|
||||||
|
{search ? 'Ничего не найдено' : 'Нет чатов. Создайте новый!'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map(chat => (
|
||||||
|
<ChatListItem
|
||||||
|
key={chat.id}
|
||||||
|
chat={chat}
|
||||||
|
active={activeChat?.id === chat.id}
|
||||||
|
online={chat.type === 'private' && chat.privateUserId ? onlineUsers.has(chat.privateUserId) : undefined}
|
||||||
|
onClick={() => openChat(chat)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Chat Area */}
|
||||||
|
<div className={`flex-1 flex flex-col ${!mobileChatOpen ? 'hidden md:flex' : 'flex'}`}>
|
||||||
|
{activeChat && fullChat ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<ChatHeader
|
||||||
|
chat={fullChat}
|
||||||
|
onBack={() => { setMobileChatOpen(false); setActiveChat(null); }}
|
||||||
|
onRefresh={() => openChat(activeChat)}
|
||||||
|
onShowInfo={() => setShowInfo(!showInfo)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-hidden bg-gray-50">
|
||||||
|
<MessageList
|
||||||
|
chatId={activeChat.id}
|
||||||
|
isGroup={activeChat.type !== 'private'}
|
||||||
|
canSend={canSend}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Panel */}
|
||||||
|
{showInfo && (
|
||||||
|
<ChatInfoPanel
|
||||||
|
chat={fullChat}
|
||||||
|
onClose={() => setShowInfo(false)}
|
||||||
|
onRefresh={() => openChat(activeChat)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="hidden md:flex flex-1 items-center justify-center text-gray-400 flex-col gap-4">
|
||||||
|
<div className="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-12 h-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-medium text-gray-600">JaniChat</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Выберите чат или создайте новый</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{showNew && <NewChatModal onClose={() => setShowNew(false)} onOpen={(id) => {
|
||||||
|
const chat = chats.find(c => c.id === id);
|
||||||
|
if (chat) openChat(chat);
|
||||||
|
}} />}
|
||||||
|
{showAdmin && <AdminPanel onClose={() => setShowAdmin(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/src/store/index.ts
Normal file
121
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { Chat, Message, User } from '../types';
|
||||||
|
|
||||||
|
interface AppStore {
|
||||||
|
user: User | null;
|
||||||
|
chats: Chat[];
|
||||||
|
activeChat: Chat | null;
|
||||||
|
messages: Record<string, Message[]>;
|
||||||
|
onlineUsers: Set<string>;
|
||||||
|
typingUsers: Record<string, string[]>;
|
||||||
|
connected: boolean;
|
||||||
|
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
setChats: (chats: Chat[]) => void;
|
||||||
|
updateChat: (chat: Partial<Chat> & { id: string }) => void;
|
||||||
|
setActiveChat: (chat: Chat | null) => void;
|
||||||
|
setMessages: (chatId: string, messages: Message[]) => void;
|
||||||
|
prependMessages: (chatId: string, messages: Message[]) => void;
|
||||||
|
addMessage: (message: Message) => void;
|
||||||
|
updateMessage: (message: Message) => void;
|
||||||
|
removeMessage: (chatId: string, messageId: string) => void;
|
||||||
|
setOnline: (userId: string, online: boolean) => void;
|
||||||
|
setTyping: (chatId: string, userId: string, typing: boolean) => void;
|
||||||
|
setConnected: (v: boolean) => void;
|
||||||
|
markRead: (chatId: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppStore>((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
chats: [],
|
||||||
|
activeChat: null,
|
||||||
|
messages: {},
|
||||||
|
onlineUsers: new Set(),
|
||||||
|
typingUsers: {},
|
||||||
|
connected: false,
|
||||||
|
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
|
||||||
|
setChats: (chats) => set({ chats }),
|
||||||
|
|
||||||
|
updateChat: (partial) => set(state => ({
|
||||||
|
chats: state.chats.map(c => c.id === partial.id ? { ...c, ...partial } : c),
|
||||||
|
activeChat: state.activeChat?.id === partial.id ? { ...state.activeChat, ...partial } : state.activeChat,
|
||||||
|
})),
|
||||||
|
|
||||||
|
setActiveChat: (chat) => set({ activeChat: chat }),
|
||||||
|
|
||||||
|
setMessages: (chatId, messages) => set(state => ({
|
||||||
|
messages: { ...state.messages, [chatId]: messages }
|
||||||
|
})),
|
||||||
|
|
||||||
|
prependMessages: (chatId, messages) => set(state => ({
|
||||||
|
messages: {
|
||||||
|
...state.messages,
|
||||||
|
[chatId]: [...messages, ...(state.messages[chatId] || [])]
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
addMessage: (message) => set(state => {
|
||||||
|
const existing = state.messages[message.chatId] || [];
|
||||||
|
if (existing.find(m => m.id === message.id)) return state;
|
||||||
|
const updated = [...existing, message];
|
||||||
|
// Update chat last message
|
||||||
|
const chats = state.chats.map(c =>
|
||||||
|
c.id === message.chatId
|
||||||
|
? { ...c, lastMessage: message.content, lastMessageAt: message.createdAt,
|
||||||
|
unreadCount: state.activeChat?.id === message.chatId ? 0 : c.unreadCount + 1 }
|
||||||
|
: c
|
||||||
|
).sort((a, b) => {
|
||||||
|
const ta = a.lastMessageAt || a.createdAt;
|
||||||
|
const tb = b.lastMessageAt || b.createdAt;
|
||||||
|
return new Date(tb).getTime() - new Date(ta).getTime();
|
||||||
|
});
|
||||||
|
return { messages: { ...state.messages, [message.chatId]: updated }, chats };
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateMessage: (message) => set(state => ({
|
||||||
|
messages: {
|
||||||
|
...state.messages,
|
||||||
|
[message.chatId]: (state.messages[message.chatId] || []).map(m =>
|
||||||
|
m.id === message.id ? message : m
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeMessage: (chatId, messageId) => set(state => ({
|
||||||
|
messages: {
|
||||||
|
...state.messages,
|
||||||
|
[chatId]: (state.messages[chatId] || []).map(m =>
|
||||||
|
m.id === messageId ? { ...m, isDeleted: true, content: 'Сообщение удалено' } : m
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
setOnline: (userId, online) => set(state => {
|
||||||
|
const next = new Set(state.onlineUsers);
|
||||||
|
online ? next.add(userId) : next.delete(userId);
|
||||||
|
return { onlineUsers: next };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setTyping: (chatId, userId, typing) => set(state => {
|
||||||
|
const current = state.typingUsers[chatId] || [];
|
||||||
|
const next = typing
|
||||||
|
? [...new Set([...current, userId])]
|
||||||
|
: current.filter(id => id !== userId);
|
||||||
|
return { typingUsers: { ...state.typingUsers, [chatId]: next } };
|
||||||
|
}),
|
||||||
|
|
||||||
|
setConnected: (v) => set({ connected: v }),
|
||||||
|
|
||||||
|
markRead: (chatId) => set(state => ({
|
||||||
|
chats: state.chats.map(c => c.id === chatId ? { ...c, unreadCount: 0 } : c)
|
||||||
|
})),
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('jc_token');
|
||||||
|
localStorage.removeItem('jc_user');
|
||||||
|
set({ user: null, chats: [], activeChat: null, messages: {}, connected: false });
|
||||||
|
},
|
||||||
|
}));
|
||||||
74
frontend/src/types.ts
Normal file
74
frontend/src/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarColor: string;
|
||||||
|
bio?: string;
|
||||||
|
phone?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
lastSeen?: string;
|
||||||
|
online?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMember {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarColor: string;
|
||||||
|
role: 'owner' | 'admin' | 'member';
|
||||||
|
online: boolean;
|
||||||
|
lastSeen: string;
|
||||||
|
canSendMessages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chat {
|
||||||
|
id: string;
|
||||||
|
type: 'private' | 'group' | 'channel';
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
avatarColor: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
role: 'owner' | 'admin' | 'member';
|
||||||
|
lastMessage?: string;
|
||||||
|
lastMessageAt?: string;
|
||||||
|
unreadCount: number;
|
||||||
|
memberCount: number;
|
||||||
|
privateUserId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
members?: ChatMember[];
|
||||||
|
myRole?: string;
|
||||||
|
canSendMessages?: boolean;
|
||||||
|
canAddMembers?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
chatId: string;
|
||||||
|
content: string;
|
||||||
|
type: 'text' | 'image' | 'file' | 'system';
|
||||||
|
isDeleted: boolean;
|
||||||
|
isEdited: boolean;
|
||||||
|
editedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
replyTo?: {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
senderName: string;
|
||||||
|
} | null;
|
||||||
|
attachments: Attachment[];
|
||||||
|
sender: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarColor: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
6
frontend/tailwind.config.js
Normal file
6
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
'/uploads': { target: 'http://localhost:3001', changeOrigin: true },
|
||||||
|
'/ws': { target: 'ws://localhost:3001', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
58
nginx-janichat.conf
Normal file
58
nginx-janichat.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user