diff --git a/backend/src/db.ts b/backend/src/db.ts index 5dda889..0a49cea 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -86,6 +86,12 @@ export async function initDB() { CREATE INDEX IF NOT EXISTS idx_chat_members_user_id ON chat_members(user_id); `); + // Migrations: add avatar columns if not exist + await pool.query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL; + ALTER TABLE chats ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT NULL; + `); + // Seed admin if no users const { rows } = await pool.query('SELECT COUNT(*) FROM users'); if (parseInt(rows[0].count) === 0) { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 47f1376..49e2179 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,7 +1,18 @@ import { FastifyInstance } from 'fastify'; import bcrypt from 'bcryptjs'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; import { pool } from '../db.js'; +function userDto(u: any) { + return { + id: u.id, username: u.username, displayName: u.display_name, + avatarColor: u.avatar_color, avatar: u.avatar || null, + bio: u.bio, phone: u.phone, isAdmin: u.is_admin, + }; +} + export default async function authRoutes(app: FastifyInstance) { app.post('/login', async (req, reply) => { const { username, password } = req.body as { username: string; password: string }; @@ -18,34 +29,15 @@ export default async function authRoutes(app: FastifyInstance) { 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, - } - }; + return { token, user: userDto(user) }; }); 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] + 'SELECT id, username, display_name, avatar_color, avatar, 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, - }; + return { ...userDto(user), lastSeen: user.last_seen }; }); app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => { @@ -66,11 +58,35 @@ export default async function authRoutes(app: FastifyInstance) { ); const { rows: [user] } = await pool.query( - 'SELECT id, username, display_name, avatar_color, bio, phone, is_admin FROM users WHERE id = $1', [id] + 'SELECT id, username, display_name, avatar_color, avatar, 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, - }; + return userDto(user); + }); + + // Upload profile avatar + app.put('/avatar', { preHandler: [app.authenticate] }, async (req, reply) => { + const { id: userId } = req.user as { id: string }; + + const data = await req.file(); + if (!data) return reply.status(400).send({ error: 'No file' }); + if (!data.mimetype.startsWith('image/')) return reply.status(400).send({ error: 'Only images allowed' }); + + const ext = path.extname(data.filename) || '.jpg'; + const filename = `u_${userId}_${Date.now()}${ext}`; + const dir = '/uploads/avatars'; + fs.mkdirSync(dir, { recursive: true }); + const buffer = await data.toBuffer(); + fs.writeFileSync(path.join(dir, filename), buffer); + + const url = `/uploads/avatars/${filename}`; + await pool.query('UPDATE users SET avatar = $1 WHERE id = $2', [url, userId]); + return { avatar: url }; + }); + + // Delete profile avatar + app.delete('/avatar', { preHandler: [app.authenticate] }, async (req) => { + const { id: userId } = req.user as { id: string }; + await pool.query('UPDATE users SET avatar = NULL WHERE id = $1', [userId]); + return { ok: true }; }); } diff --git a/backend/src/routes/chats.ts b/backend/src/routes/chats.ts index 2f1977a..dffe6f8 100644 --- a/backend/src/routes/chats.ts +++ b/backend/src/routes/chats.ts @@ -1,4 +1,6 @@ import { FastifyInstance } from 'fastify'; +import path from 'path'; +import fs from 'fs'; import { pool } from '../db.js'; import { connections } from '../ws.js'; @@ -13,7 +15,7 @@ export default async function chatRoutes(app: FastifyInstance) { const { rows } = await pool.query(` SELECT - c.id, c.type, c.title, c.description, c.avatar_color, c.is_public, c.created_at, + c.id, c.type, c.title, c.description, c.avatar_color, c.avatar, 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, @@ -30,6 +32,11 @@ export default async function chatRoutes(app: FastifyInstance) { 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.avatar 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_avatar, CASE WHEN c.type = 'private' THEN ( SELECT u.id FROM users u JOIN chat_members cm2 ON cm2.user_id = u.id @@ -46,6 +53,7 @@ export default async function chatRoutes(app: FastifyInstance) { title: r.type === 'private' ? r.private_name : r.title, description: r.description, avatarColor: r.type === 'private' ? r.private_color : r.avatar_color, + avatar: r.type === 'private' ? (r.private_avatar || null) : (r.avatar || null), isPublic: r.is_public, role: r.role, lastMessage: r.last_message, @@ -72,7 +80,7 @@ export default async function chatRoutes(app: FastifyInstance) { 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 + SELECT u.id, u.username, u.display_name, u.avatar_color, u.avatar, 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 @@ -87,6 +95,7 @@ export default async function chatRoutes(app: FastifyInstance) { title: chat.title, description: chat.description, avatarColor: chat.avatar_color, + avatar: chat.avatar || null, isPublic: chat.is_public, myRole: member.role, canSendMessages: member.can_send_messages, @@ -94,7 +103,8 @@ export default async function chatRoutes(app: FastifyInstance) { 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), + avatarColor: m.avatar_color, avatar: m.avatar || null, + role: m.role, online: onlineSet.has(m.id), lastSeen: m.last_seen, canSendMessages: m.can_send_messages, })), }; @@ -169,6 +179,48 @@ export default async function chatRoutes(app: FastifyInstance) { return reply.status(201).send({ id: chat.id }); }); + // Upload chat avatar + app.put('/:id/avatar', 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 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' }); + } + + const data = await req.file(); + if (!data) return reply.status(400).send({ error: 'No file' }); + if (!data.mimetype.startsWith('image/')) return reply.status(400).send({ error: 'Only images allowed' }); + + const ext = path.extname(data.filename) || '.jpg'; + const filename = `c_${id}_${Date.now()}${ext}`; + const dir = '/uploads/avatars'; + fs.mkdirSync(dir, { recursive: true }); + const buffer = await data.toBuffer(); + fs.writeFileSync(path.join(dir, filename), buffer); + + const url = `/uploads/avatars/${filename}`; + await pool.query('UPDATE chats SET avatar = $1 WHERE id = $2', [url, id]); + return { avatar: url }; + }); + + // Delete chat avatar + app.delete('/:id/avatar', 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 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 avatar = NULL WHERE id = $1', [id]); + return { ok: true }; + }); + // Update chat app.put('/:id', async (req, reply) => { const { id: userId } = req.user as { id: string }; diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index c8be403..a9e834f 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -1,9 +1,11 @@ interface Props { name: string; color: string; + avatar?: string | null; size?: 'sm' | 'md' | 'lg' | 'xl'; online?: boolean; className?: string; + onClick?: () => void; } const sizes = { @@ -13,21 +15,46 @@ const sizes = { 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(); +const dotSizes = { + sm: 'w-2.5 h-2.5', + md: 'w-3 h-3', + lg: 'w-3.5 h-3.5', + xl: 'w-4 h-4', +}; + +export default function Avatar({ name, color, avatar, size = 'md', online, className = '', onClick }: Props) { + const initials = name + ? name.split(' ').map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() + : '?'; + + const apiBase = import.meta.env.VITE_API_URL || ''; + + // Prepend API base URL to relative paths + const avatarUrl = avatar + ? (avatar.startsWith('http') ? avatar : `${apiBase}${avatar}`) + : null; return ( -
-
- {initials} -
+
+ {avatarUrl ? ( + {name} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) : ( +
+ {initials} +
+ )} {online !== undefined && (
+ } ${dotSizes[size]}`} /> )}
); diff --git a/frontend/src/components/ChatInfoPanel.tsx b/frontend/src/components/ChatInfoPanel.tsx index 8fcf3f1..07cd620 100644 --- a/frontend/src/components/ChatInfoPanel.tsx +++ b/frontend/src/components/ChatInfoPanel.tsx @@ -1,5 +1,5 @@ -import { X, UserPlus, Crown, Shield, User, Trash2, Ban } from 'lucide-react'; -import { useState, useEffect } from 'react'; +import { X, UserPlus, Crown, Shield, User, Trash2, Ban, Camera } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; import { Chat, ChatMember } from '../types'; import Avatar from './Avatar'; import { useStore } from '../store'; @@ -19,6 +19,9 @@ export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) { const [search, setSearch] = useState(''); const [title, setTitle] = useState(chat.title || ''); const [editing, setEditing] = useState(false); + const [chatAvatar, setChatAvatar] = useState(chat.avatar || null); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileRef = useRef(null); const isOwnerOrAdmin = chat.myRole === 'owner' || chat.myRole === 'admin' || me?.isAdmin; @@ -28,6 +31,32 @@ export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) { } }, [addMode]); + async function handleChatAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + setUploadingAvatar(true); + const form = new FormData(); + form.append('file', file); + try { + const { data } = await api.put(`/api/chats/${chat.id}/avatar`, form, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + setChatAvatar(data.avatar); + onRefresh(); + } catch { + // silent + } finally { + setUploadingAvatar(false); + } + } + + async function handleRemoveChatAvatar() { + await api.delete(`/api/chats/${chat.id}/avatar`); + setChatAvatar(null); + onRefresh(); + } + async function saveTitle() { await api.put(`/api/chats/${chat.id}`, { title }); setEditing(false); @@ -84,7 +113,29 @@ export default function ChatInfoPanel({ chat, onClose, onRefresh }: Props) {
{/* Avatar & Title */}
- +
+ + {uploadingAvatar && ( +
+
+
+ )} + {isOwnerOrAdmin && chat.type !== 'private' && ( + + )} +
+ {isOwnerOrAdmin && chat.type !== 'private' && chatAvatar && ( + + )} +
{editing && chat.type !== 'private' ? (
diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx new file mode 100644 index 0000000..d249e57 --- /dev/null +++ b/frontend/src/components/ProfileModal.tsx @@ -0,0 +1,206 @@ +import { useState, useRef } from 'react'; +import { X, Camera, Trash2, Save, Lock } from 'lucide-react'; +import { useStore } from '../store'; +import api from '../api/client'; +import Avatar from './Avatar'; + +interface Props { + onClose: () => void; +} + +export default function ProfileModal({ onClose }: Props) { + const { user, setUser } = useStore(); + const [displayName, setDisplayName] = useState(user?.displayName || ''); + const [bio, setBio] = useState(user?.bio || ''); + const [phone, setPhone] = useState(user?.phone || ''); + const [password, setPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [saving, setSaving] = useState(false); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const fileRef = useRef(null); + + async function handleSave() { + setSaving(true); + setError(''); + setSuccess(''); + try { + const body: any = { displayName, bio, phone }; + if (newPassword) { + if (!password) { setError('Введите текущий пароль'); setSaving(false); return; } + body.password = password; + body.newPassword = newPassword; + } + const { data } = await api.put('/api/auth/me', body); + setUser({ ...user!, ...data }); + localStorage.setItem('jc_user', JSON.stringify({ ...user!, ...data })); + setPassword(''); + setNewPassword(''); + setSuccess('Сохранено'); + setTimeout(() => setSuccess(''), 3000); + } catch (err: any) { + setError(err.response?.data?.error || 'Ошибка сохранения'); + } finally { + setSaving(false); + } + } + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + e.target.value = ''; + + setUploadingAvatar(true); + const form = new FormData(); + form.append('file', file); + try { + const { data } = await api.put('/api/auth/avatar', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + const updated = { ...user!, avatar: data.avatar }; + setUser(updated); + localStorage.setItem('jc_user', JSON.stringify(updated)); + } catch { + setError('Ошибка загрузки фото'); + } finally { + setUploadingAvatar(false); + } + } + + async function handleRemoveAvatar() { + if (!user?.avatar) return; + try { + await api.delete('/api/auth/avatar'); + const updated = { ...user!, avatar: null }; + setUser(updated); + localStorage.setItem('jc_user', JSON.stringify(updated)); + } catch { + setError('Ошибка'); + } + } + + return ( +
+
+
+

Мой профиль

+ +
+ +
+ {/* Avatar */} +
+
+ + {uploadingAvatar && ( +
+
+
+ )} +
+
+ + {user?.avatar && ( + + )} +
+ +
+ + {/* Info */} +
+
+ +
@{user?.username}
+
+
+ + setDisplayName(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="Ваше имя" + /> +
+
+ +