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 ( -
{error}
} + {success &&{success}
} +