first commit

This commit is contained in:
vuongpm 2026-01-05 17:41:57 +07:00
commit 1eaf20042f
64 changed files with 28180 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Node modules
meeting-backend/node_modules/
meeting-frontend/node_modules/
# Environment files
meeting-backend/.env
meeting-frontend/.env
# Build outputs
meeting-frontend/build/
meeting-backend/dist/
# Logs
*.log
# SSL Certificates (sensitive data - do not commit)
certs/
*.pem
*.key
*.crt
# Nginx config (may contain sensitive paths)
# nginx/

1
README.md Normal file
View File

@ -0,0 +1 @@
Meeting App

77
docker-compose.yml Normal file
View File

@ -0,0 +1,77 @@
version: "3.8"
services:
# ===============================
# MongoDB
# ===============================
mongo:
image: mongo:6.0
container_name: meeting-mongo
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example123
volumes:
- mongo_data:/data/db
networks:
- meeting-net
# ===============================
# Backend
# ===============================
backend:
build:
context: ./meeting-backend
container_name: meeting-backend
restart: unless-stopped
env_file:
- ./meeting-backend/.env
environment:
- MONGO_URI=mongodb://root:example123@mongo:27017/meetingDB?authSource=admin
- CLIENT_URL=https://bkmeeting.soict.io
- PORT=5000
expose:
- "5000"
depends_on:
- mongo
networks:
- meeting-net
# ===============================
# Frontend
# ===============================
frontend:
build:
context: ./meeting-frontend
container_name: meeting-frontend
restart: unless-stopped
expose:
- "80"
networks:
- meeting-net
# ===============================
# Nginx Reverse Proxy (HTTPS)
# ===============================
nginx:
image: nginx:stable-alpine
container_name: meeting-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/letsencrypt/live/bkmeeting.soict.io:ro
depends_on:
- backend
- frontend
networks:
- meeting-net
volumes:
mongo_data:
networks:
meeting-net:
driver: bridge

View File

@ -0,0 +1,7 @@
node_modules
npm-debug.log
.env
test
uploads
.vscode
.git

31
meeting-backend/.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# Node modules
node_modules/
**/node_modules/
# Environment files
.env
**/.env
# Build outputs
build/
dist/
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS files
.DS_Store
Thumbs.db
# IDE settings
.vscode/
.idea/
# Others
package-lock.json

View File

@ -0,0 +1,20 @@
# ===============================
# 1. Dùng Node.js image nhẹ cho backend
# ===============================
FROM node:18-alpine
# Thiết lập thư mục làm việc trong container
WORKDIR /app
# Copy file package và cài dependencies (npm ci = cài chính xác version)
COPY package*.json ./
RUN npm install
# Copy toàn bộ code backend vào container
COPY . .
# Expose port của backend (trùng với PORT trong .env hoặc server.js)
EXPOSE 5000
# Lệnh chạy server
CMD ["node", "src/server.js"]

View File

@ -0,0 +1,37 @@
{
"name": "meeting-backend",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node --max-old-space-size=4096 src/server.js",
"dev": "nodemon --exec \"node --max-old-space-size=4096\" src/server.js",
"seed": "node scripts/seedAdmin.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.13.2",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-session": "^1.17.3",
"form-data": "^4.0.5",
"jsonwebtoken": "^9.0.2",
"mammoth": "^1.6.0",
"mongoose": "^8.19.2",
"multer": "^2.0.2",
"openai": "^4.28.0",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"pdf-parse": "^1.1.1",
"socket.io": "^4.8.1"
},
"devDependencies": {
"nodemon": "^3.1.10"
}
}

View File

@ -0,0 +1,46 @@
import mongoose from "mongoose"
import bcrypt from "bcryptjs"
import dotenv from "dotenv"
import User from "../src/models/User.js"
dotenv.config()
const seedAdmin = async () => {
try {
// Connect to MongoDB
await mongoose.connect("mongodb://root:example123@bkmeeting.soict.io:27017/meetingDB?authSource=admin")
console.log("✓ Kết nối MongoDB thành công")
// Check if admin already exists
const existingAdmin = await User.findOne({ role: "admin" })
if (existingAdmin) {
console.log("✓ Admin đã tồn tại:", existingAdmin.email)
await mongoose.connection.close()
return
}
const hashedPassword = await bcrypt.hash("admin123", 10)
const adminUser = new User({
email: "admin@meeting.com",
fullName: "Admin",
phone: "+84123456789",
username: "admin",
password: hashedPassword,
role: "admin",
approved: true, // Admin is automatically approved
})
await adminUser.save()
console.log("✓ Tài khoản admin được tạo thành công!")
console.log(" Email: admin@meeting.com")
console.log(" Mật khẩu: admin123")
console.log(" (Vui lòng đổi mật khẩu sau khi đăng nhập)")
await mongoose.connection.close()
} catch (error) {
console.error("Lỗi:", error.message)
process.exit(1)
}
}
seedAdmin()

View File

@ -0,0 +1,13 @@
import mongoose from "mongoose";
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGO_URI);
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
};
export default connectDB;

View File

@ -0,0 +1,30 @@
import jwt from "jsonwebtoken"
import dotenv from "dotenv"
dotenv.config()
export const verifyToken = (req, res, next) => {
try {
const token = req.headers.authorization?.split(" ")[1]
if (!token) {
return res.status(401).json({ message: "Token không được cung cấp" })
}
const decoded = jwt.verify(token, process.env.JWT_SECRET)
req.user = decoded
next()
} catch (error) {
res.status(401).json({ message: "Token không hợp lệ hoặc đã hết hạn" })
}
}
export const isAdmin = (req, res, next) => {
try {
if (req.user.role !== "admin") {
return res.status(403).json({ message: "Chỉ admin mới có quyền truy cập" })
}
next()
} catch (error) {
res.status(403).json({ message: "Lỗi kiểm tra quyền" })
}
}

View File

@ -0,0 +1,55 @@
import multer from "multer"
import path from "path"
import { fileURLToPath } from "url"
import fs from "fs"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Create uploads/audio directory if it doesn't exist
const uploadsDir = path.join(__dirname, "../../uploads/audio")
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
// Configure storage for audio files
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir)
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9)
const ext = path.extname(file.originalname || "") || ".wav"
cb(null, uniqueSuffix + ext)
},
})
// File filter for audio files
const fileFilter = (req, file, cb) => {
const allowedTypes = [
"audio/wav",
"audio/webm",
"audio/ogg",
"audio/mpeg",
"audio/mp3",
]
if (allowedTypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error("File type not supported. Only WAV, WEBM, OGG, MP3 are allowed."), false)
}
}
// Multer instance for audio
export const uploadAudio = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 100 * 1024 * 1024, // 100MB max for audio recordings
},
})
// Single audio file upload middleware
export const uploadAudioSingle = uploadAudio.single("audio")

View File

@ -0,0 +1,56 @@
import multer from "multer"
import path from "path"
import { fileURLToPath } from "url"
import fs from "fs"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// Create uploads directory if it doesn't exist
const uploadsDir = path.join(__dirname, "../../uploads")
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true })
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadsDir)
},
filename: (req, file, cb) => {
// Preserve original filename encoding by using Buffer
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9)
// Keep original extension but sanitize filename
const ext = path.extname(file.originalname || "")
cb(null, uniqueSuffix + ext)
},
})
// File filter
const fileFilter = (req, file, cb) => {
const allowedTypes = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/msword", // .doc
"text/plain",
"text/markdown",
]
if (allowedTypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error("File type not supported. Only PDF, DOCX, DOC, TXT, MD are allowed."), false)
}
}
// Multer instance
export const upload = multer({
storage: storage,
fileFilter: fileFilter,
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max
},
})
// Single file upload middleware
export const uploadSingle = upload.single("document")

View File

@ -0,0 +1,35 @@
import mongoose from "mongoose"
const documentSchema = new mongoose.Schema({
meetingId: { type: mongoose.Schema.Types.ObjectId, ref: "Meeting", required: true },
roomId: { type: String, required: true }, // For quick lookup
uploadedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
fileName: { type: String, required: true },
originalName: { type: String, required: true },
filePath: { type: String, required: true },
fileSize: { type: Number, required: true }, // in bytes
mimeType: { type: String, required: true },
status: { type: String, enum: ["processing", "processed", "error"], default: "processing" },
chunks: [
{
text: String,
chunkIndex: Number,
embedding: [Number], // Vector embedding
metadata: {
page: Number,
startChar: Number,
endChar: Number,
},
},
],
processedAt: Date,
errorMessage: String,
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
})
// Index for vector search (if using MongoDB Atlas)
documentSchema.index({ "chunks.embedding": "2dsphere" })
documentSchema.index({ meetingId: 1, status: 1 })
export default mongoose.model("Document", documentSchema)

View File

@ -0,0 +1,16 @@
import mongoose from "mongoose"
const meetingSchema = new mongoose.Schema({
title: { type: String, required: true },
description: String,
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
participants: [{ type: mongoose.Schema.Types.ObjectId, ref: "User" }],
startTime: Date,
endTime: Date,
status: { type: String, enum: ["scheduled", "ongoing", "completed"], default: "scheduled" },
roomId: { type: String, unique: true, required: true },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
})
export default mongoose.model("Meeting", meetingSchema)

View File

@ -0,0 +1,41 @@
import mongoose from "mongoose"
const meetingMinutesSchema = new mongoose.Schema({
meetingId: { type: mongoose.Schema.Types.ObjectId, ref: "Meeting", required: true },
roomId: { type: String, required: true }, // For quick lookup
recordedBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
// Audio file info
audioFileName: { type: String, required: true },
audioFilePath: { type: String, required: true },
audioFileSize: { type: Number, required: true }, // in bytes
audioMimeType: { type: String, default: "audio/wav" },
// Video file info (optional, for future)
videoFileName: { type: String },
videoFilePath: { type: String },
videoFileSize: { type: Number },
// Transcription from speech-to-text service
transcription: { type: String },
transcriptionStatus: {
type: String,
enum: ["pending", "processing", "completed", "error"],
default: "pending"
},
transcriptionError: { type: String },
// Recording metadata
recordingDuration: { type: Number }, // in seconds
startTime: { type: Date, required: true },
endTime: { type: Date },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now },
})
meetingMinutesSchema.index({ meetingId: 1, roomId: 1 })
meetingMinutesSchema.index({ recordedBy: 1 })
export default mongoose.model("MeetingMinutes", meetingMinutesSchema)

View File

@ -0,0 +1,15 @@
import mongoose from "mongoose"
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
fullName: { type: String, required: true },
phone: { type: String },
username: { type: String, unique: true, sparse: true }, // Optional for OAuth users
password: { type: String }, // Optional for OAuth users
googleId: { type: String, unique: true, sparse: true }, // For Google OAuth
role: { type: String, enum: ["admin", "user"], default: "user" },
approved: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now },
})
export default mongoose.model("User", userSchema)

View File

@ -0,0 +1,128 @@
import express from "express"
import bcrypt from "bcryptjs" // Dùng để mã hóa mật khẩu
import jwt from "jsonwebtoken" // Dùng để tạo token đăng nhập (JWT)
import dotenv from "dotenv" // Dùng để đọc biến môi trường (.env)
import User from "../models/User.js" // Import model User để thao tác với MongoDB
dotenv.config() // Nạp biến môi trường từ file .env
const router = express.Router() // Tạo router Express riêng cho các route /auth
// Register
router.post("/register", async (req, res) => {
try {
const { email, fullName, phone, password } = req.body
// Kiểm tra xem người dùng đã tồn tại chưa (theo email hoặc username)
const existing = await User.findOne({ $or: [{ email }, { username: email }] })
if (existing) return res.status(400).json({ message: "Email đã được đăng ký" })
// Mã hóa mật khẩu bằng bcrypt trước khi lưu
const hashedPassword = await bcrypt.hash(password, 10)
// Tạo user mới (chưa được admin duyệt)
const user = new User({
email,
fullName,
phone,
username: email, // Dùng email làm username
password: hashedPassword, // Lưu mật khẩu đã mã hóa
role: "user", // Mặc định là người dùng thường
approved: false, // Cần admin duyệt mới được đăng nhập
})
// Lưu người dùng vào cơ sở dữ liệu MongoDB
await user.save()
// Phản hồi về cho client
res.json({
message: "Đăng ký thành công. Tài khoản sẽ hoạt động sau khi admin duyệt.",
})
} catch (error) {
// Nếu có lỗi, trả về lỗi 500
res.status(500).json({ message: error.message })
}
})
// Đăng nhập tài khoản (Login)
router.post("/login", async (req, res) => {
try {
const { email, password } = req.body // Lấy email và mật khẩu từ client gửi lên
// Tìm người dùng theo email
const user = await User.findOne({ email })
if (!user) return res.status(400).json({ message: "Email không tồn tại" })
// Kiểm tra xem tài khoản đã được admin duyệt chưa
if (!user.approved) {
return res.status(403).json({ message: "Tài khoản của bạn chưa được admin duyệt." })
}
// Kiểm tra mật khẩu có đúng không (so sánh với mật khẩu đã mã hóa)
const isMatch = await bcrypt.compare(password, user.password)
if (!isMatch) return res.status(400).json({ message: "Mật khẩu không chính xác" })
// Tạo JWT token chứa thông tin người dùng (id, role, email)
const token = jwt.sign(
{ id: user._id, role: user.role, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
)
// Trả token và thông tin cơ bản của user về cho client
res.json({
token,
role: user.role,
user: { id: user._id, email: user.email, fullName: user.fullName },
})
} catch (error) {
// Nếu có lỗi, trả về lỗi 500
res.status(500).json({ message: error.message })
}
})
// Đăng nhập bằng tài khoản Google (Google OAuth)
router.post("/google-callback", async (req, res) => {
try {
const { googleId, email, fullName } = req.body // Nhận dữ liệu từ client (sau khi Google xác thực)
// Tìm người dùng theo googleId
let user = await User.findOne({ googleId })
// Nếu chưa có, tạo người dùng mới từ thông tin Google
if (!user) {
user = new User({
googleId,
email,
fullName,
role: "user", // Mặc định là user
approved: false, // Cần admin duyệt trước khi sử dụng
})
await user.save()
}
// Nếu tài khoản chưa được admin duyệt thì chặn lại
if (!user.approved) {
return res.status(403).json({ message: "Tài khoản của bạn chưa được admin duyệt." })
}
// Nếu đã được duyệt, tạo token đăng nhập
const token = jwt.sign(
{ id: user._id, role: user.role, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: "7d" }
)
// Gửi token và thông tin người dùng về client
res.json({
token,
role: user.role,
user: { id: user._id, email: user.email, fullName: user.fullName },
})
} catch (error) {
// Nếu có lỗi, trả về lỗi 500
res.status(500).json({ message: error.message })
}
})
// Xuất router để có thể dùng trong server.js
export default router

View File

@ -0,0 +1,345 @@
import express from "express"
import { verifyToken, isAdmin } from "../middleware/authMiddleware.js"
import { uploadSingle } from "../middleware/uploadMiddleware.js"
import Document from "../models/Document.js"
import Meeting from "../models/Meeting.js"
import { processDocument } from "../services/documentProcessor.js"
import { generateRAGAnswer } from "../services/ragService.js"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const router = express.Router()
// Upload document for meeting (only admin)
router.post("/upload", verifyToken, isAdmin, uploadSingle, async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ message: "Không có file được upload" })
}
const { meetingId, roomId } = req.body
if (!meetingId && !roomId) {
return res.status(400).json({ message: "Meeting ID hoặc Room ID là bắt buộc" })
}
// Find meeting
let meeting
if (meetingId) {
meeting = await Meeting.findById(meetingId)
} else if (roomId) {
meeting = await Meeting.findOne({ roomId })
}
if (!meeting) {
return res.status(404).json({ message: "Cuộc họp không tồn tại" })
}
// Fix filename encoding - ensure UTF-8
// Multer sometimes receives filenames in wrong encoding (latin1 instead of utf-8)
let originalName = req.file.originalname || ""
try {
// If filename contains mojibake characters, try to fix encoding
if (typeof originalName === "string") {
// Check if it looks like mojibake (contains common mojibake patterns)
if (/Ã|â|áº|táº|á»/.test(originalName)) {
console.log(`[Document] Detected potential encoding issue: "${originalName}"`)
// Try to fix: convert from latin1 misinterpretation back to utf-8
// This handles cases where UTF-8 bytes were read as latin1
try {
const fixed = Buffer.from(originalName, "latin1").toString("utf-8")
if (fixed && fixed !== originalName && /[àáảãạăằắẳẵặâầấẩẫậèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÈÉẺẼẸÊỀẾỂỄỆÌÍỈĨỊÒÓỎÕỌÔỒỐỔỖỘƠỜỚỞỠỢÙÚỦŨỤƯỪỨỬỮỰỲÝỶỸỴĐ]/.test(fixed)) {
console.log(`[Document] Fixed encoding: "${fixed}"`)
originalName = fixed
}
} catch (e) {
console.warn("[Document] Could not fix encoding, using original")
}
}
}
} catch (error) {
console.warn("[Document] Error decoding filename, using original:", error)
}
// Create document record
const document = new Document({
meetingId: meeting._id,
roomId: meeting.roomId || roomId,
uploadedBy: req.user.id,
fileName: req.file.filename,
originalName: originalName,
filePath: req.file.path,
fileSize: req.file.size,
mimeType: req.file.mimetype,
status: "processing",
})
await document.save()
// Process document asynchronously (with file path verification)
console.log(`[Document] Starting processing for document ${document._id}: ${document.originalName}`)
console.log(`[Document] File path: ${req.file.path}`)
// Verify file exists before processing
const fs = (await import("fs")).default
if (!fs.existsSync(req.file.path)) {
document.status = "error"
document.errorMessage = `File not found at path: ${req.file.path}`
await document.save()
return res.status(500).json({ message: "File không tồn tại sau khi upload" })
}
processDocument(req.file.path, req.file.mimetype)
.then(async (chunks) => {
console.log(`[Document] Document ${document._id} processed successfully with ${chunks.length} chunks`)
document.chunks = chunks
document.status = "processed"
document.processedAt = new Date()
document.chunksProcessed = chunks.length
await document.save()
console.log(`[Document] Document ${document._id} saved successfully`)
})
.catch(async (error) => {
console.error(`[Document] Error processing document ${document._id}:`, error)
document.status = "error"
document.errorMessage = error.message || "Lỗi không xác định"
await document.save()
})
res.json({
message: "Tài liệu đã được upload thành công. Đang xử lý...",
document: {
id: document._id,
fileName: document.originalName,
fileSize: document.fileSize,
status: document.status,
},
})
} catch (error) {
console.error("Error uploading document:", error)
res.status(500).json({ message: error.message || "Lỗi khi upload tài liệu" })
}
})
// Get documents for meeting
router.get("/meeting/:roomId", verifyToken, async (req, res) => {
try {
const { roomId } = req.params
const documents = await Document.find({ roomId })
.populate("uploadedBy", "fullName email")
.sort({ createdAt: -1 })
res.json(documents)
} catch (error) {
console.error("Error fetching documents:", error)
res.status(500).json({ message: error.message || "Lỗi khi lấy danh sách tài liệu" })
}
})
// Download document (also support query param token for direct access)
router.get("/download/:id", async (req, res) => {
try {
// Support both header token and query param token
let token = req.headers.authorization?.split(" ")[1] || req.query.token
if (!token) {
return res.status(401).json({ message: "Token không được cung cấp" })
}
// Verify token
const jwt = (await import("jsonwebtoken")).default
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const document = await Document.findById(req.params.id)
if (!document) {
return res.status(404).json({ message: "Tài liệu không tồn tại" })
}
// Check if file exists
const fs = (await import("fs")).default
if (!fs.existsSync(document.filePath)) {
return res.status(404).json({ message: "File không tồn tại" })
}
// Sanitize filename to avoid encoding issues
const safeFileName = document.originalName
.replace(/[^\w\s.-]/g, "_")
.replace(/\s+/g, "_")
.substring(0, 200) // Limit length
// Set headers for download
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"; filename*=UTF-8''${encodeURIComponent(document.originalName)}`)
res.setHeader("Content-Type", document.mimeType || "application/octet-stream")
// Stream file
fs.createReadStream(document.filePath).pipe(res)
} catch (error) {
console.error("Error downloading document:", error)
if (error.name === "JsonWebTokenError" || error.name === "TokenExpiredError") {
return res.status(401).json({ message: "Token không hợp lệ" })
}
res.status(500).json({ message: error.message || "Lỗi khi tải tài liệu" })
}
})
// Update document (only admin)
router.put("/:id", verifyToken, isAdmin, uploadSingle, async (req, res) => {
try {
const document = await Document.findById(req.params.id)
if (!document) {
return res.status(404).json({ message: "Tài liệu không tồn tại" })
}
if (!req.file) {
return res.status(400).json({ message: "Không có file được upload" })
}
// Delete old file
const fs = (await import("fs")).default
try {
if (fs.existsSync(document.filePath)) {
fs.unlinkSync(document.filePath)
}
} catch (err) {
console.error("Error deleting old file:", err)
}
// Fix filename encoding
let originalName = req.file.originalname || ""
try {
if (typeof originalName === "string") {
if (/Ã|â|áº|táº|á»/.test(originalName)) {
console.log(`[Document] Detected potential encoding issue: "${originalName}"`)
try {
const fixed = Buffer.from(originalName, "latin1").toString("utf-8")
if (fixed && fixed !== originalName && /[àáảãạăằắẳẵặâầấẩẫậèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÈÉẺẼẸÊỀẾỂỄỆÌÍỈĨỊÒÓỎÕỌÔỒỐỔỖỘƠỜỚỞỠỢÙÚỦŨỤƯỪỨỬỮỰỲÝỶỸỴĐ]/.test(fixed)) {
console.log(`[Document] Fixed encoding: "${fixed}"`)
originalName = fixed
}
} catch (e) {
console.warn("[Document] Could not fix encoding, using original")
}
}
}
} catch (error) {
console.warn("[Document] Error decoding filename, using original:", error)
}
// Update document
document.fileName = req.file.filename
document.originalName = originalName
document.filePath = req.file.path
document.fileSize = req.file.size
document.mimeType = req.file.mimetype
document.status = "processing"
document.chunks = []
document.processedAt = null
document.chunksProcessed = 0
document.errorMessage = null
await document.save()
// Process document asynchronously
console.log(`[Document] Starting processing for updated document ${document._id}: ${document.originalName}`)
const fsCheck = (await import("fs")).default
if (!fsCheck.existsSync(req.file.path)) {
document.status = "error"
document.errorMessage = `File not found at path: ${req.file.path}`
await document.save()
return res.status(500).json({ message: "File không tồn tại sau khi upload" })
}
processDocument(req.file.path, req.file.mimetype)
.then(async (chunks) => {
console.log(`[Document] Document ${document._id} processed successfully with ${chunks.length} chunks`)
document.chunks = chunks
document.status = "processed"
document.processedAt = new Date()
document.chunksProcessed = chunks.length
await document.save()
console.log(`[Document] Document ${document._id} saved successfully`)
})
.catch(async (error) => {
console.error(`[Document] Error processing document ${document._id}:`, error)
document.status = "error"
document.errorMessage = error.message || "Lỗi không xác định"
await document.save()
})
res.json({
message: "Tài liệu đã được cập nhật thành công. Đang xử lý...",
document: {
id: document._id,
fileName: document.originalName,
fileSize: document.fileSize,
status: document.status,
},
})
} catch (error) {
console.error("Error updating document:", error)
res.status(500).json({ message: error.message || "Lỗi khi cập nhật tài liệu" })
}
})
// Delete document (only admin)
router.delete("/:id", verifyToken, isAdmin, async (req, res) => {
try {
const document = await Document.findById(req.params.id)
if (!document) {
return res.status(404).json({ message: "Tài liệu không tồn tại" })
}
// Delete file
const fs = (await import("fs")).default
try {
fs.unlinkSync(document.filePath)
} catch (err) {
console.error("Error deleting file:", err)
}
await Document.findByIdAndDelete(req.params.id)
res.json({ message: "Tài liệu đã được xóa thành công" })
} catch (error) {
console.error("Error deleting document:", error)
res.status(500).json({ message: error.message || "Lỗi khi xóa tài liệu" })
}
})
// RAG Chat - Ask question about documents
router.post("/rag/chat", verifyToken, async (req, res) => {
try {
const { roomId, query } = req.body
if (!roomId || !query) {
return res.status(400).json({ message: "Room ID và câu hỏi là bắt buộc" })
}
// Generate RAG answer
const result = await generateRAGAnswer(roomId, query)
res.json({
query: query,
answer: result.answer,
sources: result.sources,
confidence: result.confidence,
timestamp: new Date(),
})
} catch (error) {
console.error("Error in RAG chat:", error)
res.status(500).json({
message: error.message || "Lỗi khi xử lý câu hỏi",
answer: "Xin lỗi, có lỗi xảy ra khi xử lý câu hỏi của bạn.",
})
}
})
export default router

View File

@ -0,0 +1,167 @@
import express from "express"
import Meeting from "../models/Meeting.js"
import { verifyToken, isAdmin } from "../middleware/authMiddleware.js"
import crypto from "crypto"
const router = express.Router()
// Hàm tạo ngẫu nhiên mã phòng họp (roomId) duy nhất
const generateRoomId = () => {
return crypto.randomBytes(8).toString("hex")
}
// API: Tạo cuộc họp (chỉ Admin)
router.post("/", verifyToken, isAdmin, async (req, res) => {
try {
const { title, description, startTime, endTime } = req.body
// Tạo mã phòng (roomId)
let roomId = generateRoomId()
let existingMeeting = await Meeting.findOne({ roomId })
// Nếu mã phòng bị trùng thì tạo lại cho đến khi unique
while (existingMeeting) {
roomId = generateRoomId()
existingMeeting = await Meeting.findOne({ roomId })
}
// Lưu thông tin cuộc họp vào MongoDB
const meeting = new Meeting({
title,
description,
startTime,
endTime,
roomId,
createdBy: req.user.id, // id admin tạo cuộc họp
participants: [req.user.id], // người tạo tự động tham gia
})
await meeting.save()
res.json({ message: "Cuộc họp được tạo thành công", meeting })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Lấy danh sách tất cả cuộc họp
router.get("/", verifyToken, async (req, res) => {
try {
const meetings = await Meeting.find()
.populate("createdBy", "email fullName") // Lấy thêm thông tin người tạo
.populate("participants", "email fullName") // Lấy thêm thông tin người tham gia
.sort({ createdAt: -1 }) // Sắp xếp theo thời gian mới nhất
res.json(meetings)
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Lấy thông tin chi tiết cuộc họp theo ID
router.get("/:id", verifyToken, async (req, res) => {
try {
let meeting = await Meeting.findById(req.params.id)
.populate("createdBy", "email fullName")
.populate("participants", "email fullName")
if (!meeting) return res.status(404).json({ message: "Cuộc họp không tồn tại" })
// Với các bản ghi cũ chưa có roomId, tự động tạo roomId và lưu lại
if (!meeting.roomId) {
let roomId = generateRoomId()
let existingMeeting = await Meeting.findOne({ roomId })
while (existingMeeting) {
roomId = generateRoomId()
existingMeeting = await Meeting.findOne({ roomId })
}
meeting.roomId = roomId
await meeting.save()
}
res.json(meeting)
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Lấy thông tin cuộc họp theo roomId
router.get("/room/:roomId", verifyToken, async (req, res) => {
try {
const meeting = await Meeting.findOne({ roomId: req.params.roomId })
.populate("createdBy", "email fullName")
.populate("participants", "email fullName")
if (!meeting) return res.status(404).json({ message: "Phòng họp không tồn tại" })
res.json(meeting)
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Tham gia cuộc họp bằng ID
router.post("/:id/join", verifyToken, async (req, res) => {
try {
const meeting = await Meeting.findById(req.params.id)
if (!meeting) return res.status(404).json({ message: "Cuộc họp không tồn tại" })
// Nếu người dùng chưa có trong danh sách, thêm vào
if (!meeting.participants.some((p) => p?.toString() === req.user.id)) {
meeting.participants.push(req.user.id)
await meeting.save()
}
res.json({ message: "Tham gia cuộc họp thành công", meeting })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Tham gia cuộc họp bằng roomId
router.post("/room/:roomId/join", verifyToken, async (req, res) => {
try {
const meeting = await Meeting.findOne({ roomId: req.params.roomId })
if (!meeting) return res.status(404).json({ message: "Phòng họp không tồn tại" })
if (!meeting.participants.some((p) => p?.toString() === req.user.id)) {
meeting.participants.push(req.user.id)
await meeting.save()
}
res.json({ message: "Tham gia cuộc họp thành công", meeting })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Cập nhật thông tin cuộc họp (Admin)
router.put("/:id", verifyToken, isAdmin, async (req, res) => {
try {
const { title, description, startTime, endTime, status } = req.body
const meeting = await Meeting.findByIdAndUpdate(
req.params.id,
{ title, description, startTime, endTime, status, updatedAt: Date.now() },
{ new: true } // trả về bản ghi sau khi update
)
if (!meeting) return res.status(404).json({ message: "Cuộc họp không tồn tại" })
res.json({ message: "Cập nhật cuộc họp thành công", meeting })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// API: Xoá cuộc họp (Admin)
router.delete("/:id", verifyToken, isAdmin, async (req, res) => {
try {
const meeting = await Meeting.findByIdAndDelete(req.params.id)
if (!meeting) return res.status(404).json({ message: "Cuộc họp không tồn tại" })
res.json({ message: "Xóa cuộc họp thành công" })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
export default router

View File

@ -0,0 +1,240 @@
import express from "express"
import { verifyToken } from "../middleware/authMiddleware.js"
import { uploadAudioSingle } from "../middleware/uploadAudioMiddleware.js"
import MeetingMinutes from "../models/MeetingMinutes.js"
import Meeting from "../models/Meeting.js"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
import FormData from "form-data"
import axios from "axios"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const router = express.Router()
// Upload recording and process with speech-to-text
router.post("/upload", verifyToken, uploadAudioSingle, async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ message: "Không có file được upload" })
}
const { roomId, recordingDuration, startTime, endTime } = req.body
if (!roomId) {
return res.status(400).json({ message: "Room ID là bắt buộc" })
}
// Find meeting
const meeting = await Meeting.findOne({ roomId })
if (!meeting) {
return res.status(404).json({ message: "Cuộc họp không tồn tại" })
}
// Create meeting minutes record
const meetingMinutes = new MeetingMinutes({
meetingId: meeting._id,
roomId: roomId,
recordedBy: req.user.id,
audioFileName: req.file.filename,
audioFilePath: req.file.path,
audioFileSize: req.file.size,
audioMimeType: req.file.mimetype,
recordingDuration: recordingDuration ? parseInt(recordingDuration) : null,
startTime: startTime ? new Date(startTime) : new Date(),
endTime: endTime ? new Date(endTime) : new Date(),
transcriptionStatus: "pending",
})
await meetingMinutes.save()
// Process transcription asynchronously
processTranscription(meetingMinutes._id, req.file.path)
.catch((error) => {
console.error(`[MeetingMinutes] Error processing transcription for ${meetingMinutes._id}:`, error)
})
res.json({
message: "File ghi âm đã được upload thành công. Đang xử lý chuyển đổi văn bản...",
minutes: {
id: meetingMinutes._id,
audioFileName: meetingMinutes.audioFileName,
transcriptionStatus: meetingMinutes.transcriptionStatus,
},
})
} catch (error) {
console.error("Error uploading meeting minutes:", error)
res.status(500).json({ message: error.message || "Lỗi khi upload file ghi âm" })
}
})
// Process transcription using Python service
async function processTranscription(minutesId, audioFilePath) {
try {
const meetingMinutes = await MeetingMinutes.findById(minutesId)
if (!meetingMinutes) {
console.error(`[MeetingMinutes] Minutes not found: ${minutesId}`)
return
}
meetingMinutes.transcriptionStatus = "processing"
await meetingMinutes.save()
// Call Python speech-to-text service
// Adjust the URL based on your Python service configuration
// Try multiple possible endpoints
const PYTHON_SERVICE_URL = process.env.PYTHON_SERVICE_URL || "http://localhost:8000"
const PYTHON_ENDPOINT = process.env.PYTHON_ENDPOINT || "/speech-to-text"
// Check if Python service URL is configured
if (!PYTHON_SERVICE_URL || PYTHON_SERVICE_URL === "http://localhost:8000") {
console.warn(`[MeetingMinutes] Python service URL not configured, trying default localhost:8000`)
}
try {
// Read audio file and send to Python service
const formData = new FormData()
formData.append("audio", fs.createReadStream(audioFilePath), {
filename: path.basename(audioFilePath),
contentType: "audio/wav",
})
// Use axios to send file to Python service
// Timeout after 5 minutes for long recordings
console.log(`[MeetingMinutes] Calling Python service: ${PYTHON_SERVICE_URL}${PYTHON_ENDPOINT}`)
const response = await axios.post(`${PYTHON_SERVICE_URL}${PYTHON_ENDPOINT}`, formData, {
headers: formData.getHeaders(),
maxContentLength: Infinity,
maxBodyLength: Infinity,
timeout: 300000, // 5 minutes
})
if (response.data && response.data.transcription) {
meetingMinutes.transcription = response.data.transcription
meetingMinutes.transcriptionStatus = "completed"
await meetingMinutes.save()
console.log(`[MeetingMinutes] Transcription completed for ${minutesId}`)
} else if (response.data && response.data.text) {
// Alternative response format
meetingMinutes.transcription = response.data.text
meetingMinutes.transcriptionStatus = "completed"
await meetingMinutes.save()
console.log(`[MeetingMinutes] Transcription completed for ${minutesId}`)
} else {
throw new Error("Không nhận được dữ liệu transcription từ service")
}
} catch (axiosError) {
// If Python service is not available, just log and mark as error
console.error(`[MeetingMinutes] Python service error:`, axiosError.message)
meetingMinutes.transcriptionStatus = "error"
meetingMinutes.transcriptionError = axiosError.message || "Không thể kết nối đến Python service"
await meetingMinutes.save()
}
} catch (error) {
console.error(`[MeetingMinutes] Error processing transcription:`, error)
const meetingMinutes = await MeetingMinutes.findById(minutesId)
if (meetingMinutes) {
meetingMinutes.transcriptionStatus = "error"
meetingMinutes.transcriptionError = error.message || "Lỗi không xác định"
await meetingMinutes.save()
}
}
}
// Get meeting minutes for a room
router.get("/meeting/:roomId", verifyToken, async (req, res) => {
try {
const { roomId } = req.params
const minutes = await MeetingMinutes.find({ roomId })
.populate("recordedBy", "fullName email")
.sort({ createdAt: -1 })
res.json(minutes)
} catch (error) {
console.error("Error fetching meeting minutes:", error)
res.status(500).json({ message: error.message || "Lỗi khi lấy danh sách biên bản" })
}
})
// Get single meeting minutes by ID
router.get("/:id", verifyToken, async (req, res) => {
try {
const minutes = await MeetingMinutes.findById(req.params.id)
.populate("recordedBy", "fullName email")
.populate("meetingId", "title description")
if (!minutes) {
return res.status(404).json({ message: "Biên bản không tồn tại" })
}
res.json(minutes)
} catch (error) {
console.error("Error fetching meeting minutes:", error)
res.status(500).json({ message: error.message || "Lỗi khi lấy biên bản" })
}
})
// Download audio file
router.get("/:id/audio", verifyToken, async (req, res) => {
try {
const minutes = await MeetingMinutes.findById(req.params.id)
if (!minutes) {
return res.status(404).json({ message: "Biên bản không tồn tại" })
}
// Check if file exists
if (!fs.existsSync(minutes.audioFilePath)) {
return res.status(404).json({ message: "File audio không tồn tại" })
}
// Set headers for audio playback
res.setHeader("Content-Type", minutes.audioMimeType || "audio/wav")
res.setHeader("Content-Length", minutes.audioFileSize)
res.setHeader("Accept-Ranges", "bytes")
// Stream file
fs.createReadStream(minutes.audioFilePath).pipe(res)
} catch (error) {
console.error("Error downloading audio:", error)
res.status(500).json({ message: error.message || "Lỗi khi tải file audio" })
}
})
// Delete meeting minutes
router.delete("/:id", verifyToken, async (req, res) => {
try {
const minutes = await MeetingMinutes.findById(req.params.id)
if (!minutes) {
return res.status(404).json({ message: "Biên bản không tồn tại" })
}
// Chỉ admin mới có thể xóa biên bản
if (req.user.role !== "admin") {
return res.status(403).json({ message: "Chỉ admin mới có quyền xóa biên bản" })
}
// Delete audio file
try {
if (fs.existsSync(minutes.audioFilePath)) {
fs.unlinkSync(minutes.audioFilePath)
}
} catch (err) {
console.error("Error deleting audio file:", err)
}
await MeetingMinutes.findByIdAndDelete(req.params.id)
res.json({ message: "Biên bản đã được xóa thành công" })
} catch (error) {
console.error("Error deleting meeting minutes:", error)
res.status(500).json({ message: error.message || "Lỗi khi xóa biên bản" })
}
})
export default router

View File

@ -0,0 +1,64 @@
import express from "express"
import User from "../models/User.js"
import { verifyToken, isAdmin } from "../middleware/authMiddleware.js"
const router = express.Router()
// Lấy danh sách người dùng đang chờ duyệt - admin (GET /users/pending)
router.get("/pending", verifyToken, isAdmin, async (req, res) => {
try {
const pendingUsers = await User.find(
{ approved: false },
"email fullName phone role createdAt"
)
// Gửi danh sách người dùng chờ duyệt về client
res.json(pendingUsers)
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// Thống kê tổng số người dùng (GET /users/stats)
router.get("/stats", verifyToken, isAdmin, async (req, res) => {
try {
const totalUsers = await User.countDocuments()
const pendingUsers = await User.countDocuments({ approved: false })
const approvedUsers = await User.countDocuments({ approved: true })
res.json({ totalUsers, pendingUsers, approvedUsers })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// Duyệt tài khoản người dùng (PATCH /users/approve/:id)
router.patch("/approve/:id", verifyToken, isAdmin, async (req, res) => {
try {
// Tìm user theo id được truyền trong URL
const user = await User.findById(req.params.id)
if (!user) return res.status(404).json({ message: "User not found" })
// Cập nhật trạng thái approved = true
user.approved = true
await user.save()
// Gửi phản hồi về client
res.json({ message: `User ${user.email} đã được duyệt.` })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
// Xóa tài khoản người dùng (DELETE /users/:id)
router.delete("/:id", verifyToken, isAdmin, async (req, res) => {
try {
// Xóa user theo id được truyền trong URL
await User.findByIdAndDelete(req.params.id)
res.json({ message: "User đã bị xóa." })
} catch (error) {
res.status(500).json({ message: error.message })
}
})
export default router

View File

@ -0,0 +1,44 @@
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { createServer } from "http";
import { Server } from "socket.io";
import connectDB from "./config/db.js";
import authRoutes from "./routes/auth.js";
import meetingRoutes from "./routes/meeting.js";
import userRoutes from "./routes/user.js";
import documentRoutes from "./routes/document.js";
import minutesRoutes from "./routes/minutes.js";
import { initializeSocket } from "./socket/socketHandler.js";
dotenv.config();
connectDB();
const app = express();
app.use(cors());
app.use(express.json());
// Create HTTP server
const httpServer = createServer(app);
// Initialize Socket.IO with CORS configuration
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL || "https://bkmeeting.soict.io",
methods: ["GET", "POST"],
credentials: true,
},
});
// Initialize socket handlers
initializeSocket(io);
// Routes
app.use("/api/auth", authRoutes);
app.use("/api/meetings", meetingRoutes);
app.use("/api/users", userRoutes);
app.use("/api/documents", documentRoutes);
app.use("/api/minutes", minutesRoutes);
const PORT = process.env.PORT || 5000;
httpServer.listen(PORT, () => console.log(`Server running on port ${PORT}`));

View File

@ -0,0 +1,242 @@
import fs from "fs/promises"
import mammoth from "mammoth"
import OpenAI from "openai"
import dotenv from "dotenv"
dotenv.config()
// Lazy initialization of OpenRouter client (compatible with OpenAI SDK)
// OpenRouter provides free API access at https://openrouter.ai
let openai = null
const getOpenAI = () => {
if (!openai) {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY or OPENAI_API_KEY not configured in .env file. Get your free API key at https://openrouter.ai/settings/keys")
}
openai = new OpenAI({
apiKey: apiKey,
baseURL: "https://openrouter.ai/api/v1", // OpenRouter API endpoint
defaultHeaders: {
"HTTP-Referer": process.env.OPENROUTER_HTTP_REFERER || "http://bkmeeting.soict.io:5000", // Optional: for tracking
"X-Title": process.env.OPENROUTER_APP_NAME || "Meeting App", // Optional: for tracking
},
})
}
return openai
}
// Dynamic import for pdf-parse to avoid ESM issue with test files
let pdfParse = null
let pdfParseLoading = false
const loadPdfParse = async () => {
if (pdfParse) return pdfParse
if (pdfParseLoading) {
// Wait if already loading
while (pdfParseLoading) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
return pdfParse
}
pdfParseLoading = true
try {
// Create test file before importing to avoid ENOENT error
// pdf-parse tries to read a test file on module load
const fs = await import("fs/promises")
const path = await import("path")
const { fileURLToPath } = await import("url")
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const testDir = path.join(__dirname, "../../node_modules/pdf-parse/test/data")
const testFile = path.join(testDir, "05-versions-space.pdf")
try {
await fs.mkdir(testDir, { recursive: true })
// Create an empty dummy file if it doesn't exist
try {
await fs.access(testFile)
} catch {
await fs.writeFile(testFile, Buffer.alloc(0))
}
} catch (err) {
// Ignore errors, try to continue anyway
console.warn("[DocumentProcessor] Could not create pdf-parse test file:", err.message)
}
// Use dynamic import with error handling
const pdfParseModule = await import("pdf-parse").catch((err) => {
console.error("[DocumentProcessor] Error importing pdf-parse:", err)
throw new Error("Failed to load PDF parser")
})
pdfParse = pdfParseModule.default || pdfParseModule
} finally {
pdfParseLoading = false
}
return pdfParse
}
// Chunk text into smaller pieces for embedding
export const chunkText = (text, chunkSize = 500, overlap = 50, maxChunks = 500) => {
const chunks = []
let start = 0
while (start < text.length && chunks.length < maxChunks) {
const end = Math.min(start + chunkSize, text.length)
const chunk = text.slice(start, end).trim()
if (chunk.length > 0) {
chunks.push({
text: chunk,
startChar: start,
endChar: end,
})
}
// Move forward with overlap
start = end - overlap
}
// Log warning if text was truncated
if (start < text.length) {
console.warn(`[DocumentProcessor] Text truncated: ${text.length} chars -> ${chunks.length} chunks (max ${maxChunks})`)
}
return chunks
}
// Extract text from different file types
export const extractText = async (filePath, mimeType) => {
try {
if (mimeType === "application/pdf") {
// Verify file exists first
try {
await fs.access(filePath)
} catch (error) {
throw new Error(`PDF file not found: ${filePath}`)
}
const pdfParseLib = await loadPdfParse()
const dataBuffer = await fs.readFile(filePath)
const data = await pdfParseLib(dataBuffer)
return data.text || ""
} else if (
mimeType ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
const result = await mammoth.extractRawText({ path: filePath })
return result.value
} else if (mimeType === "text/plain" || mimeType.includes("text/")) {
const data = await fs.readFile(filePath, "utf-8")
return data
} else {
throw new Error(`Unsupported file type: ${mimeType}`)
}
} catch (error) {
console.error("Error extracting text:", error)
throw error
}
}
// Generate embeddings using OpenAI (with batching to avoid memory issues)
export const generateEmbeddings = async (texts, batchSize = 100) => {
try {
const client = getOpenAI()
const allEmbeddings = []
// Process in batches to avoid memory issues
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize)
console.log(`[DocumentProcessor] Generating embeddings for batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(texts.length / batchSize)} (${batch.length} chunks)...`)
const response = await client.embeddings.create({
model: "openai/text-embedding-3-small", // OpenRouter model format: provider/model-name
input: batch,
})
allEmbeddings.push(...response.data.map((item) => item.embedding))
// Small delay to avoid rate limiting
if (i + batchSize < texts.length) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
return allEmbeddings
} catch (error) {
console.error("Error generating embeddings:", error)
throw error
}
}
// Process document: extract -> chunk -> embed
export const processDocument = async (filePath, mimeType) => {
try {
// Step 1: Extract text
console.log("Extracting text from document...")
const fullText = await extractText(filePath, mimeType)
if (!fullText || fullText.trim().length === 0) {
throw new Error("No text extracted from document")
}
// Step 2: Chunk text (with limits to avoid memory issues)
console.log(`[DocumentProcessor] Chunking text (${fullText.length} chars)...`)
// Limit text length to ~250KB to avoid memory issues (500 chunks * 500 chars)
const maxTextLength = 250000
const textToProcess = fullText.length > maxTextLength
? fullText.substring(0, maxTextLength)
: fullText
if (fullText.length > maxTextLength) {
console.warn(`[DocumentProcessor] Document text truncated from ${fullText.length} to ${maxTextLength} chars to avoid memory issues`)
}
const textChunks = chunkText(textToProcess, 500, 50, 500) // Max 500 chunks
console.log(`[DocumentProcessor] Created ${textChunks.length} chunks`)
if (textChunks.length === 0) {
throw new Error("No chunks created from document")
}
// Step 3: Generate embeddings in batches to avoid memory issues
console.log(`[DocumentProcessor] Generating embeddings for ${textChunks.length} chunks (in batches)...`)
const chunkTexts = textChunks.map((chunk) => chunk.text)
// Limit chunk text length to avoid token limits (embedding model has token limits)
const limitedChunkTexts = chunkTexts.map((text) => {
// Limit to ~8000 characters (roughly 2000 tokens for embedding model)
if (text.length > 8000) {
return text.substring(0, 8000)
}
return text
})
const embeddings = await generateEmbeddings(limitedChunkTexts, 50) // Smaller batch size: 50
// Step 4: Combine chunks with embeddings
const processedChunks = textChunks.map((chunk, index) => {
const embedding = embeddings[index]
if (!embedding || embedding.length === 0) {
console.warn(`[DocumentProcessor] Chunk ${index} has no embedding`)
}
return {
text: chunk.text,
chunkIndex: index,
embedding: embedding || [],
metadata: {
page: 1, // PDF parsing would provide page numbers
startChar: chunk.startChar,
endChar: chunk.endChar,
},
}
})
console.log(`[DocumentProcessor] Processed ${processedChunks.length} chunks with embeddings`)
return processedChunks
} catch (error) {
console.error("Error processing document:", error)
throw error
}
}

View File

@ -0,0 +1,206 @@
import OpenAI from "openai"
import Document from "../models/Document.js"
import dotenv from "dotenv"
import { generateEmbeddings } from "./documentProcessor.js"
dotenv.config()
// Lazy initialization of OpenRouter client (compatible with OpenAI SDK)
// OpenRouter provides free API access at https://openrouter.ai
let openai = null
const getOpenAI = () => {
if (!openai) {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENAI_API_KEY
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY or OPENAI_API_KEY not configured in .env file. Get your free API key at https://openrouter.ai/settings/keys")
}
openai = new OpenAI({
apiKey: apiKey,
baseURL: "https://openrouter.ai/api/v1", // OpenRouter API endpoint
defaultHeaders: {
"HTTP-Referer": process.env.OPENROUTER_HTTP_REFERER || "http://bkmeeting.soict.io:5000", // Optional: for tracking
"X-Title": process.env.OPENROUTER_APP_NAME || "Meeting App", // Optional: for tracking
},
})
}
return openai
}
// Cosine similarity for vector comparison
const cosineSimilarity = (vecA, vecB) => {
if (vecA.length !== vecB.length) return 0
let dotProduct = 0
let normA = 0
let normB = 0
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i]
normA += vecA[i] * vecA[i]
normB += vecB[i] * vecB[i]
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))
}
// Retrieve relevant chunks based on query
export const retrieveRelevantChunks = async (roomId, query, topK = 5) => {
try {
console.log(`[RAG] Retrieving chunks for roomId: ${roomId}, query: "${query}"`)
// Step 1: Get all processed documents for this meeting
const documents = await Document.find({
roomId: roomId,
status: "processed",
}).populate("uploadedBy", "fullName email")
console.log(`[RAG] Found ${documents.length} processed documents for room ${roomId}`)
if (documents.length === 0) {
// Check if there are any documents at all
const allDocs = await Document.find({ roomId }).select("status originalName")
console.log(`[RAG] Total documents in room: ${allDocs.length}`)
allDocs.forEach((doc) => {
console.log(`[RAG] - ${doc.originalName}: status=${doc.status}`)
})
return []
}
// Check if documents have chunks
let totalChunks = 0
documents.forEach((doc) => {
totalChunks += doc.chunks?.length || 0
})
console.log(`[RAG] Total chunks across all documents: ${totalChunks}`)
if (totalChunks === 0) {
console.log(`[RAG] No chunks found in processed documents`)
return []
}
// Step 2: Convert query to embedding
console.log(`[RAG] Generating query embedding...`)
const [queryEmbedding] = await generateEmbeddings([query])
console.log(`[RAG] Query embedding generated, length: ${queryEmbedding.length}`)
// Step 3: Calculate similarity for all chunks
const chunkScores = []
documents.forEach((doc) => {
if (!doc.chunks || doc.chunks.length === 0) {
console.log(`[RAG] Document ${doc.originalName} has no chunks`)
return
}
doc.chunks.forEach((chunk, idx) => {
if (chunk.embedding && Array.isArray(chunk.embedding) && chunk.embedding.length > 0) {
const similarity = cosineSimilarity(queryEmbedding, chunk.embedding)
chunkScores.push({
chunk: chunk,
similarity: similarity,
document: {
fileName: doc.originalName || doc.fileName,
uploadedBy: doc.uploadedBy?.fullName || "Unknown",
uploadedAt: doc.createdAt,
},
})
} else {
console.log(`[RAG] Chunk ${idx} in ${doc.originalName} has no valid embedding`)
}
})
})
console.log(`[RAG] Calculated similarity for ${chunkScores.length} chunks`)
// Step 4: Sort by similarity and return top K
chunkScores.sort((a, b) => b.similarity - a.similarity)
const topChunks = chunkScores.slice(0, topK)
console.log(`[RAG] Returning top ${topChunks.length} chunks`)
if (topChunks.length > 0) {
console.log(`[RAG] Top similarity: ${topChunks[0].similarity.toFixed(4)}`)
}
return topChunks
} catch (error) {
console.error("[RAG] Error retrieving chunks:", error)
throw error
}
}
// Generate answer using RAG
export const generateRAGAnswer = async (roomId, query) => {
try {
// Step 1: Retrieve relevant chunks
const relevantChunks = await retrieveRelevantChunks(roomId, query, 5)
if (relevantChunks.length === 0) {
return {
answer: "Xin lỗi, không tìm thấy thông tin liên quan trong các tài liệu đã upload. Vui lòng upload tài liệu hoặc hỏi câu hỏi khác.",
sources: [],
confidence: 0,
}
}
// Step 2: Build context from relevant chunks
const context = relevantChunks
.map(
(item, index) =>
`[Tài liệu ${index + 1}: ${item.document.fileName}]\n${item.chunk.text}`
)
.join("\n\n---\n\n")
// Step 3: Generate answer using GPT with context
const prompt = `Bạn là một trợ lý AI giúp trả lời câu hỏi dựa trên các tài liệu đã được upload trong cuộc họp.
Các đoạn tài liệu liên quan:
${context}
Câu hỏi của người dùng: ${query}
Yêu cầu:
1. Trả lời câu hỏi dựa trên các đoạn tài liệu trên
2. Nếu không đủ thông tin, nói "Không đủ thông tin trong tài liệu"
3. Trích dẫn nguồn (tên file) khi thể
4. Trả lời bằng tiếng Việt, ngắn gọn chính xác
Trả lời:`
const client = getOpenAI()
const response = await client.chat.completions.create({
model: "openai/gpt-3.5-turbo", // OpenRouter model format: provider/model-name
messages: [
{
role: "system",
content:
"Bạn là trợ lý AI giúp trả lời câu hỏi dựa trên tài liệu. Trả lời bằng tiếng Việt, ngắn gọn và chính xác.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
max_tokens: 500,
})
const answer = response.choices[0].message.content
// Step 4: Extract sources
const sources = relevantChunks.map((item) => ({
fileName: item.document.fileName,
uploadedBy: item.document.uploadedBy,
similarity: item.similarity,
text: (item.chunk.text || "").substring(0, 200) + "...", // Preview
}))
return {
answer: answer,
sources: sources,
confidence: relevantChunks[0]?.similarity || 0,
}
} catch (error) {
console.error("Error generating RAG answer:", error)
throw error
}
}

View File

@ -0,0 +1,470 @@
import jwt from "jsonwebtoken"
import User from "../models/User.js"
import Meeting from "../models/Meeting.js"
import dotenv from "dotenv"
import crypto from "crypto"
dotenv.config()
// Danh sách các phòng họp đang hoạt động (roomId → Map các người tham gia)
const activeRooms = new Map()
// Bản đồ ánh xạ userId → socketId (để gửi tin nhắn riêng - private message)
const userSocketMap = new Map()
// ===============================
// 🚀 Hàm khởi tạo Socket.IO
// ===============================
export const initializeSocket = (io) => {
// 🧩 Middleware xác thực mỗi khi có client kết nối qua socket
io.use(async (socket, next) => {
try {
// Lấy token từ client (gửi kèm trong phần auth hoặc header)
const token =
socket.handshake.auth.token ||
socket.handshake.headers.authorization?.split(" ")[1]
if (!token) {
return next(new Error("Token không được cung cấp"))
}
// Giải mã token để xác định người dùng
const decoded = jwt.verify(token, process.env.JWT_SECRET)
const user = await User.findById(decoded.id)
// Nếu user không tồn tại hoặc chưa được admin duyệt → từ chối kết nối
if (!user || !user.approved) {
return next(new Error("Người dùng chưa được phê duyệt"))
}
// Lưu thông tin user vào socket (để dùng ở các sự kiện sau)
socket.user = {
id: user._id.toString(),
email: user.email,
fullName: user.fullName,
role: user.role, // Thêm role để kiểm tra admin
}
next() // Cho phép kết nối tiếp tục
} catch (error) {
next(new Error("Token không hợp lệ"))
}
})
// ===============================
// 🔌 Xử lý khi người dùng kết nối thành công
// ===============================
io.on("connection", (socket) => {
console.log(`User connected: ${socket.user.fullName} (${socket.id})`)
// Lưu ánh xạ user → socket (để hỗ trợ gửi tin nhắn riêng)
userSocketMap.set(socket.user.id, socket.id)
// =====================================
// 👥 Sự kiện: Người dùng tham gia phòng họp
// =====================================
socket.on("join-meeting", async (data) => {
try {
const { roomId, meetingId } = data
// Kiểm tra tham số đầu vào
if (!roomId && !meetingId) {
socket.emit("error", { message: "Room ID hoặc Meeting ID là bắt buộc" })
return
}
// Tìm thông tin cuộc họp trong MongoDB
let meeting
if (meetingId) {
meeting = await Meeting.findById(meetingId)
} else if (roomId) {
meeting = await Meeting.findOne({ roomId })
}
if (!meeting) {
socket.emit("error", { message: "Phòng họp không tồn tại" })
return
}
// ✅ Thêm người dùng vào danh sách participants trong DB nếu chưa có
if (!meeting.participants.includes(socket.user.id)) {
meeting.participants.push(socket.user.id)
await meeting.save()
}
// ✅ Socket tham gia vào phòng tương ứng (theo roomId)
socket.join(meeting.roomId)
// ✅ Nếu phòng chưa có trong RAM, khởi tạo mới
if (!activeRooms.has(meeting.roomId)) {
activeRooms.set(meeting.roomId, new Map())
}
// Lấy danh sách thành viên đang online trong phòng đó
const roomParticipants = activeRooms.get(meeting.roomId)
// Loại bỏ các entry cũ của cùng user (trường hợp refresh/reconnect)
for (const [sId, p] of roomParticipants.entries()) {
if (p.userId === socket.user.id) {
roomParticipants.delete(sId)
}
}
// Thêm người mới vào danh sách người đang hoạt động
roomParticipants.set(socket.id, {
userId: socket.user.id,
userName: socket.user.fullName,
socketId: socket.id,
joinedAt: new Date(),
})
// 🔄 Nếu cuộc họp đang ở trạng thái “đã lên lịch” → chuyển sang “đang diễn ra”
if (meeting.status === "scheduled") {
meeting.status = "ongoing"
await meeting.save()
}
// ✅ Gửi phản hồi cho chính người dùng là họ đã tham gia thành công
socket.emit("joined-meeting", {
meetingId: meeting._id.toString(),
roomId: meeting.roomId,
title: meeting.title,
description: meeting.description,
})
// 🔔 Gửi thông báo đến những người khác trong phòng rằng có người mới vào
// Dedupe theo userId để không hiển thị trùng
const seen = new Set()
const participantsList = []
for (const p of roomParticipants.values()) {
if (!seen.has(p.userId)) {
seen.add(p.userId)
participantsList.push(p)
}
}
socket.to(meeting.roomId).emit("user-joined", {
user: {
id: socket.user.id,
name: socket.user.fullName,
},
participants: participantsList,
})
// 👥 Gửi danh sách người đang trong phòng cho người vừa mới vào
socket.emit("current-participants", { participants: participantsList })
console.log(`${socket.user.fullName} joined room ${meeting.roomId}`)
} catch (error) {
console.error("Error joining meeting:", error)
socket.emit("error", { message: "Lỗi khi tham gia cuộc họp" })
}
})
// =====================================
// 🚪 Sự kiện: Rời khỏi phòng họp
// =====================================
socket.on("leave-meeting", async (data) => {
try {
const { roomId } = data
if (roomId && activeRooms.has(roomId)) {
const roomParticipants = activeRooms.get(roomId)
roomParticipants.delete(socket.id) // Xóa người này khỏi danh sách
// Nếu phòng trống → xóa khỏi danh sách activeRooms
if (roomParticipants.size === 0) {
activeRooms.delete(roomId)
} else {
// Gửi thông báo cho những người còn lại (loại trùng theo userId)
const seen = new Set()
const uniqueList = []
for (const p of roomParticipants.values()) {
if (!seen.has(p.userId)) {
seen.add(p.userId)
uniqueList.push(p)
}
}
socket.to(roomId).emit("user-left", {
user: {
id: socket.user.id,
name: socket.user.fullName,
},
participants: uniqueList,
})
}
// Socket rời khỏi phòng
socket.leave(roomId)
console.log(`${socket.user.fullName} left room ${roomId}`)
}
} catch (error) {
console.error("Error leaving meeting:", error)
}
})
// =====================================
// 💬 Sự kiện: Gửi tin nhắn (công khai hoặc riêng tư)
// =====================================
socket.on("chat-message", (data) => {
const { roomId, message, targetUserId, messageType = "public" } = data
if (!roomId || !message) return
const messageData = {
id: crypto.randomBytes(8).toString("hex"),
userId: socket.user.id,
userName: socket.user.fullName,
message,
timestamp: new Date(),
type: messageType,
targetUserId: targetUserId || null,
}
// 🔒 Tin nhắn riêng tư (1-1)
if (messageType === "private" && targetUserId) {
const targetSocketId = userSocketMap.get(targetUserId)
if (targetSocketId) {
// Gửi tin nhắn cho người nhận
io.to(targetSocketId).emit("chat-message", messageData)
// Gửi lại cho người gửi để hiển thị trong UI
socket.emit("chat-message", messageData)
} else {
socket.emit("error", { message: "Người dùng không trực tuyến" })
}
} else {
// 🌐 Tin nhắn công khai - gửi đến tất cả trong phòng
io.to(roomId).emit("chat-message", messageData)
}
})
// =====================================
// ✍️ Sự kiện: Người dùng đang nhập (typing)
// =====================================
socket.on("typing", (data) => {
const { roomId, isTyping } = data
if (roomId) {
// Gửi thông báo cho các thành viên khác trong phòng
socket.to(roomId).emit("typing", {
userId: socket.user.id,
userName: socket.user.fullName,
isTyping,
})
}
})
// =====================================
// ❌ Sự kiện: Ngắt kết nối (đóng tab, mất mạng, v.v.)
// =====================================
socket.on("disconnect", async () => {
console.log(`User disconnected: ${socket.user.fullName} (${socket.id})`)
// Xóa ánh xạ user → socket
userSocketMap.delete(socket.user.id)
// Xóa user khỏi tất cả phòng đang hoạt động
for (const [roomId, participants] of activeRooms.entries()) {
if (participants.has(socket.id)) {
participants.delete(socket.id)
if (participants.size === 0) {
activeRooms.delete(roomId)
} else {
// Thông báo cho người khác trong phòng (loại trùng theo userId)
const seen = new Set()
const uniqueList = []
for (const p of participants.values()) {
if (!seen.has(p.userId)) {
seen.add(p.userId)
uniqueList.push(p)
}
}
socket.to(roomId).emit("user-left", {
user: {
id: socket.user.id,
name: socket.user.fullName,
},
participants: uniqueList,
})
}
}
}
})
// =====================================
// ⚠️ Xử lý lỗi socket
// =====================================
socket.on("error", (error) => {
console.error("Socket error:", error)
})
// =====================================
// 📹 WebRTC Video Call Events
// =====================================
// User muốn bật/tắt video/mic/screen share
socket.on("media-toggle", (data) => {
const { roomId, mediaType, enabled } = data
if (roomId) {
// Broadcast state change đến tất cả người trong phòng
socket.to(roomId).emit("media-toggle", {
userId: socket.user.id,
userName: socket.user.fullName,
mediaType, // "video" | "audio" | "screen"
enabled,
})
}
})
// WebRTC Offer (caller gửi offer cho callee)
socket.on("webrtc-offer", (data) => {
const { roomId, targetUserId, offer } = data
console.log(`[Socket] Received webrtc-offer from ${socket.user.id} (${socket.user.fullName}) to ${targetUserId}`)
const targetSocketId = userSocketMap.get(targetUserId)
if (targetSocketId) {
console.log(`[Socket] Relaying webrtc-offer to socket ${targetSocketId} (userId: ${targetUserId})`)
io.to(targetSocketId).emit("webrtc-offer", {
fromUserId: socket.user.id,
fromUserName: socket.user.fullName,
offer,
})
} else {
console.error(`[Socket] Cannot relay webrtc-offer: targetUserId ${targetUserId} not found in userSocketMap. Current map:`, Array.from(userSocketMap.entries()).map(([uid, sid]) => ({ userId: uid, socketId: sid })))
}
})
// WebRTC Answer (callee trả lời offer)
socket.on("webrtc-answer", (data) => {
const { roomId, targetUserId, answer } = data
console.log(`[Socket] Received webrtc-answer from ${socket.user.id} (${socket.user.fullName}) to ${targetUserId}`)
const targetSocketId = userSocketMap.get(targetUserId)
if (targetSocketId) {
console.log(`[Socket] Relaying webrtc-answer to socket ${targetSocketId} (userId: ${targetUserId})`)
io.to(targetSocketId).emit("webrtc-answer", {
fromUserId: socket.user.id,
fromUserName: socket.user.fullName,
answer,
})
} else {
console.error(`[Socket] Cannot relay webrtc-answer: targetUserId ${targetUserId} not found in userSocketMap`)
}
})
// ICE Candidate (thông tin kết nối mạng)
socket.on("webrtc-ice-candidate", (data) => {
const { roomId, targetUserId, candidate } = data
const targetSocketId = userSocketMap.get(targetUserId)
if (targetSocketId) {
io.to(targetSocketId).emit("webrtc-ice-candidate", {
fromUserId: socket.user.id,
candidate,
})
} else {
console.warn(`[Socket] Cannot relay webrtc-ice-candidate: targetUserId ${targetUserId} not found`)
}
})
// User kết thúc call
socket.on("webrtc-end-call", (data) => {
const { roomId } = data
if (roomId) {
socket.to(roomId).emit("webrtc-end-call", {
userId: socket.user.id,
userName: socket.user.fullName,
})
}
})
// User yêu cầu call lại (trường hợp reconnect)
socket.on("webrtc-reconnect-request", (data) => {
const { roomId } = data
if (roomId && activeRooms.has(roomId)) {
const roomParticipants = activeRooms.get(roomId)
const seen = new Set()
const participants = []
for (const p of roomParticipants.values()) {
if (!seen.has(p.userId)) {
seen.add(p.userId)
participants.push(p)
}
}
// Gửi danh sách participants cho user đang reconnect
socket.emit("webrtc-participants-list", { participants })
}
})
// =====================================
// 🛑 Sự kiện: Admin kết thúc cuộc họp (đẩy tất cả thành viên ra)
// =====================================
socket.on("end-meeting", async (data) => {
try {
const { roomId } = data
// Chỉ admin mới có thể kết thúc cuộc họp
if (socket.user.role !== "admin") {
socket.emit("error", { message: "Chỉ admin mới có quyền kết thúc cuộc họp" })
return
}
if (!roomId || !activeRooms.has(roomId)) {
socket.emit("error", { message: "Phòng họp không tồn tại" })
return
}
const roomParticipants = activeRooms.get(roomId)
// Cập nhật trạng thái meeting trong DB
const meeting = await Meeting.findOne({ roomId })
if (meeting) {
meeting.status = "ended"
await meeting.save()
}
// Gửi event "meeting-ended" đến tất cả thành viên trong phòng
io.to(roomId).emit("meeting-ended", {
message: "Cuộc họp đã được kết thúc bởi admin",
endedBy: {
id: socket.user.id,
name: socket.user.fullName,
},
})
// Đẩy tất cả thành viên ra khỏi phòng
const participantSockets = await io.in(roomId).fetchSockets()
participantSockets.forEach((participantSocket) => {
participantSocket.leave(roomId)
// Force disconnect để đảm bảo client nhận được event
participantSocket.emit("meeting-ended", {
message: "Cuộc họp đã được kết thúc bởi admin",
endedBy: {
id: socket.user.id,
name: socket.user.fullName,
},
})
})
// Xóa phòng khỏi danh sách active
activeRooms.delete(roomId)
console.log(`Meeting ${roomId} ended by admin ${socket.user.fullName}`)
} catch (error) {
console.error("Error ending meeting:", error)
socket.emit("error", { message: "Lỗi khi kết thúc cuộc họp" })
}
})
})
return io
}
// Xuất ra để các module khác có thể truy cập danh sách phòng hoặc người dùng
export { activeRooms, userSocketMap }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
node_modules
build
npm-debug.log
.env
.git
.vscode

23
meeting-frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,26 @@
# ===============================
# Stage 1: Build React app
# ===============================
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
# Copy source và build production
COPY . .
RUN npm run build
# ===============================
# Stage 2: Dùng Nginx để serve frontend
# ===============================
FROM nginx:stable-alpine
# Copy build từ stage 1 vào thư mục của nginx
COPY --from=build /app/build /usr/share/nginx/html
# Expose port 80 (nginx default)
EXPOSE 80
# Chạy nginx
CMD ["nginx", "-g", "daemon off;"]

17598
meeting-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
{
"name": "meeting-app-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.0",
"@react-oauth/google": "^0.12.0",
"react-scripts": "5.0.1",
"socket.io-client": "^4.8.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": ["react-app"]
},
"browserslist": {
"production": [">0.2%", "not dead", "not op_mini all"],
"development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]
}
}

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Meeting App - Ứng dụng họp trực tuyến" />
<title>Meeting App</title>
</head>
<body>
<noscript>Bạn cần bật JavaScript để chạy ứng dụng này.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,4 @@
.app {
min-height: 100vh;
background: radial-gradient(circle at top left, #e8f0fe, #f4f6fb 55%, #fdfdfd);
}

View File

@ -0,0 +1,54 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom"
import { GoogleOAuthProvider } from "@react-oauth/google"
import { AuthProvider } from "./context/AuthContext"
import { ProtectedRoute } from "./components/ProtectedRoute"
import LoginPage from "./pages/LoginPage"
import RegisterPage from "./pages/RegisterPage"
import DashboardPage from "./pages/DashboardPage"
import AdminDashboardPage from "./pages/AdminDashboardPage"
import MeetingRoomPage from "./pages/MeetingRoomPage"
import "./App.css"
function App() {
const googleClientId = process.env.REACT_APP_GOOGLE_CLIENT_ID || "YOUR_GOOGLE_CLIENT_ID"
return (
<GoogleOAuthProvider clientId={googleClientId}>
<Router>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requiredRole="admin">
<AdminDashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/meeting/:roomId"
element={
<ProtectedRoute>
<MeetingRoomPage />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
</AuthProvider>
</Router>
</GoogleOAuthProvider>
)
}
export default App

View File

@ -0,0 +1,60 @@
import client from "./client"
export const authAPI = {
register: (data) => client.post("/auth/register", data),
login: (data) => client.post("/auth/login", data),
googleCallback: (data) => client.post("/auth/google-callback", data),
}
export const userAPI = {
getPendingUsers: () => client.get("/users/pending"),
getStats: () => client.get("/users/stats"),
approveUser: (id) => client.patch(`/users/approve/${id}`),
deleteUser: (id) => client.delete(`/users/${id}`),
}
export const meetingAPI = {
createMeeting: (data) => client.post("/meetings", data),
getMeetings: () => client.get("/meetings"),
getMeetingById: (id) => client.get(`/meetings/${id}`),
getMeetingByRoomId: (roomId) => client.get(`/meetings/room/${roomId}`),
joinMeetingById: (id) => client.post(`/meetings/${id}/join`),
joinMeetingByRoomId: (roomId) => client.post(`/meetings/room/${roomId}/join`),
}
export const documentAPI = {
uploadDocument: (formData) => {
// Axios sẽ tự động set Content-Type cho multipart/form-data
return client.post("/documents/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
},
getDocuments: (roomId) => client.get(`/documents/meeting/${roomId}`),
downloadDocument: (id) => {
const token = sessionStorage.getItem("token") || localStorage.getItem("token")
const API_URL = process.env.REACT_APP_API_URL || "https://bkmeeting.soict.io/api"
window.open(`${API_URL}/documents/download/${id}?token=${token}`, "_blank")
},
deleteDocument: (id) => client.delete(`/documents/${id}`),
ragChat: (data) => client.post("/documents/rag/chat", data),
}
export const minutesAPI = {
uploadRecording: (formData) => {
return client.post("/minutes/upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
},
getMinutes: (roomId) => client.get(`/minutes/meeting/${roomId}`),
getMinuteById: (id) => client.get(`/minutes/${id}`),
getAudioUrl: (id) => {
const token = sessionStorage.getItem("token") || localStorage.getItem("token")
const API_URL = process.env.REACT_APP_API_URL || "https://bkmeeting.soict.io/api"
return `${API_URL}/minutes/${id}/audio?token=${token}`
},
deleteMinute: (id) => client.delete(`/minutes/${id}`),
}

View File

@ -0,0 +1,34 @@
import axios from "axios"
const API_URL = process.env.REACT_APP_API_URL || "https://bkmeeting.soict.io/api"
const client = axios.create({
baseURL: API_URL,
headers: {
"Content-Type": "application/json",
},
})
// Add token to requests (prefer sessionStorage for per-tab isolation)
client.interceptors.request.use((config) => {
const token = sessionStorage.getItem("token") || localStorage.getItem("token")
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle responses
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token")
localStorage.removeItem("user")
window.location.href = "/login"
}
return Promise.reject(error)
},
)
export default client

View File

@ -0,0 +1,123 @@
import { useState } from "react"
import { documentAPI } from "../api/auth"
import "../styles/DocumentUpload.css"
export default function DocumentUpload({ roomId, onUploadSuccess }) {
const [selectedFile, setSelectedFile] = useState(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const handleFileSelect = (e) => {
const file = e.target.files[0]
if (file) {
// Validate file type
const allowedTypes = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"text/plain",
]
if (!allowedTypes.includes(file.type)) {
setError("Chỉ hỗ trợ file PDF, DOCX, DOC, TXT (tối đa 10MB)")
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setError("File quá lớn. Tối đa 10MB")
return
}
setSelectedFile(file)
setError(null)
setSuccess(null)
}
}
const handleUpload = async () => {
if (!selectedFile || !roomId) return
setUploading(true)
setError(null)
setSuccess(null)
try {
const formData = new FormData()
formData.append("document", selectedFile)
formData.append("roomId", roomId)
const response = await documentAPI.uploadDocument(formData)
setSuccess("Tài liệu đã được upload thành công. Đang xử lý...")
setSelectedFile(null)
// Reset file input
const fileInput = document.getElementById("document-upload-input")
if (fileInput) fileInput.value = ""
if (onUploadSuccess) {
onUploadSuccess(response.data)
}
} catch (err) {
setError(err.response?.data?.message || "Lỗi khi upload tài liệu")
} finally {
setUploading(false)
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"
return (bytes / (1024 * 1024)).toFixed(2) + " MB"
}
return (
<div className="document-upload">
<div className="upload-header">
<h3>📄 Upload Tài liệu</h3>
<p className="upload-hint">Hỗ trợ: PDF, DOCX, DOC, TXT (tối đa 10MB)</p>
</div>
<div className="upload-area">
<input
type="file"
id="document-upload-input"
accept=".pdf,.doc,.docx,.txt"
onChange={handleFileSelect}
className="file-input"
/>
<label htmlFor="document-upload-input" className="file-label">
{selectedFile ? (
<div className="file-selected">
<span className="file-icon">📄</span>
<div className="file-info">
<span className="file-name">{selectedFile.name}</span>
<span className="file-size">{formatFileSize(selectedFile.size)}</span>
</div>
</div>
) : (
<div className="file-placeholder">
<span className="upload-icon">📤</span>
<span>Chọn file để upload</span>
</div>
)}
</label>
</div>
{selectedFile && (
<button
onClick={handleUpload}
disabled={uploading}
className="btn-upload"
>
{uploading ? "Đang upload..." : "Upload"}
</button>
)}
{error && <div className="upload-error">{error}</div>}
{success && <div className="upload-success">{success}</div>}
</div>
)
}

View File

@ -0,0 +1,86 @@
"use client"
import { useNavigate, useLocation } from "react-router-dom"
import { useAuth } from "../hooks/useAuth"
import "../styles/Navbar.css"
export default function Navbar() {
const navigate = useNavigate()
const location = useLocation()
const { user, logout } = useAuth()
const handleLogout = () => {
logout()
navigate("/login")
}
const handleAdminClick = () => {
navigate("/admin")
}
const handleDashboardClick = () => {
navigate("/dashboard")
}
const isAdminPage = location.pathname === "/admin"
const isDashboardPage = location.pathname === "/dashboard"
const getInitials = () => {
const source = user?.fullName || user?.email || ""
if (!source) return "ME"
const parts = source.trim().split(" ").filter(Boolean)
if (parts.length === 0) return source.slice(0, 2).toUpperCase()
const initials = parts.map((part) => part[0]).join("")
return initials.slice(0, 2).toUpperCase()
}
return (
<nav className="navbar">
<div className="navbar-container">
<button className="navbar-brand" onClick={handleDashboardClick} aria-label="Quay lại dashboard">
<div className="brand-icon"></div>
<div className="brand-copy">
<span className="brand-title">xMeet</span>
<span className="brand-tagline">Smart meeting workspace</span>
</div>
</button>
<div className="navbar-menu">
{/* Navigation buttons for admin */}
{user?.role === "admin" && (
<div className="navbar-nav">
<button
onClick={handleDashboardClick}
className={`nav-btn ${isDashboardPage ? "active" : ""}`}
title="Dashboard"
>
<span className="nav-icon">📊</span>
<span className="nav-text">Dashboard</span>
</button>
<button
onClick={handleAdminClick}
className={`nav-btn ${isAdminPage ? "active" : ""}`}
title="Admin Panel"
>
<span className="nav-icon"></span>
<span className="nav-text">Admin</span>
</button>
</div>
)}
<div className="navbar-user">
<div className="user-info">
<span className="user-name">{user?.fullName || user?.email}</span>
<span className="user-role">{user?.role === "admin" ? "Quản trị viên" : "Thành viên"}</span>
</div>
<div className="user-avatar">{getInitials()}</div>
</div>
<button onClick={handleLogout} className="btn-logout">
Đăng xuất
</button>
</div>
</div>
</nav>
)
}

View File

@ -0,0 +1,21 @@
"use client"
import { Navigate } from "react-router-dom"
import { useAuth } from "../hooks/useAuth"
export const ProtectedRoute = ({ children, requiredRole = null }) => {
const { user, loading } = useAuth()
if (loading) {
return <div className="flex items-center justify-center h-screen">Loading...</div>
}
if (!user) {
return <Navigate to="/login" />
}
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/dashboard" />
}
return children
}

View File

@ -0,0 +1,135 @@
import { useState, useRef, useEffect } from "react"
import { documentAPI } from "../api/auth"
import "../styles/RAGChatbox.css"
export default function RAGChatbox({ roomId }) {
const [messages, setMessages] = useState([
{
role: "assistant",
content: "Xin chào! Tôi là trợ lý AI. Bạn có thể hỏi tôi về nội dung trong các tài liệu đã được upload trong cuộc họp này.",
timestamp: new Date(),
},
])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const messagesEndRef = useRef(null)
useEffect(() => {
scrollToBottom()
}, [messages])
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
const handleSend = async (e) => {
e.preventDefault()
if (!input.trim() || loading) return
const userMessage = {
role: "user",
content: input.trim(),
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setInput("")
setLoading(true)
try {
const response = await documentAPI.ragChat({
roomId: roomId,
query: input.trim(),
})
const assistantMessage = {
role: "assistant",
content: response.data.answer,
sources: response.data.sources || [],
confidence: response.data.confidence,
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
} catch (error) {
const errorMessage = {
role: "assistant",
content: error.response?.data?.answer || "Xin lỗi, có lỗi xảy ra khi xử lý câu hỏi của bạn.",
error: true,
timestamp: new Date(),
}
setMessages((prev) => [...prev, errorMessage])
} finally {
setLoading(false)
}
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString("vi-VN", {
hour: "2-digit",
minute: "2-digit",
})
}
return (
<div className="rag-chatbox">
<div className="chatbox-header">
<h3>🤖 AI Trợ </h3>
<p className="chatbox-subtitle">Hỏi về nội dung tài liệu đã upload</p>
</div>
<div className="chatbox-messages">
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">
<div className="message-text">{msg.content}</div>
{msg.sources && msg.sources.length > 0 && (
<div className="message-sources">
<div className="sources-header">📚 Nguồn tham khảo:</div>
{msg.sources.map((source, idx) => (
<div key={idx} className="source-item">
<span className="source-file">{source.fileName}</span>
<span className="source-preview">{source.text}</span>
{source.similarity && (
<span className="source-confidence">
Độ liên quan: {(source.similarity * 100).toFixed(1)}%
</span>
)}
</div>
))}
</div>
)}
<div className="message-time">{formatTime(msg.timestamp)}</div>
</div>
</div>
))}
{loading && (
<div className="message assistant">
<div className="message-content">
<div className="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSend} className="chatbox-input-form">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Nhập câu hỏi về tài liệu..."
className="chatbox-input"
disabled={loading}
/>
<button type="submit" className="btn-send" disabled={!input.trim() || loading}>
Gửi
</button>
</form>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,48 @@
"use client"
import { createContext, useState, useEffect } from "react"
export const AuthContext = createContext()
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Prefer sessionStorage (per-tab isolation). Fallback to localStorage once for backward compatibility
const sessionToken = sessionStorage.getItem("token")
const sessionUser = sessionStorage.getItem("user")
const localToken = localStorage.getItem("token")
const localUser = localStorage.getItem("user")
if (sessionToken && sessionUser) {
setToken(sessionToken)
setUser(JSON.parse(sessionUser))
} else if (localToken && localUser) {
// Migrate: load once from localStorage into sessionStorage so subsequent reloads stay tab-scoped
setToken(localToken)
setUser(JSON.parse(localUser))
sessionStorage.setItem("token", localToken)
sessionStorage.setItem("user", localUser)
}
setLoading(false)
}, [])
const login = (token, userData) => {
setToken(token)
setUser(userData)
// Store per-tab to avoid cross-tab account overwrites
sessionStorage.setItem("token", token)
sessionStorage.setItem("user", JSON.stringify(userData))
}
const logout = () => {
setToken(null)
setUser(null)
sessionStorage.removeItem("token")
sessionStorage.removeItem("user")
}
return <AuthContext.Provider value={{ user, token, loading, login, logout }}>{children}</AuthContext.Provider>
}

View File

@ -0,0 +1,12 @@
"use client"
import { useContext } from "react"
import { AuthContext } from "../context/AuthContext"
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error("useAuth must be used within AuthProvider")
}
return context
}

View File

@ -0,0 +1,74 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #4285f4;
--primary-dark: #1967d2;
--primary-soft: #e8f0fe;
--accent-pink: #ea4335;
--accent-green: #34a853;
--accent-orange: #fbbc04;
--bg-dark: #ffffff;
--bg-darker: #f5f5f5;
--surface: #ffffff;
--surface-muted: #fafafa;
--surface-glass: rgba(255, 255, 255, 0.98);
--text-light: #1a1a1a;
--text-muted: #3c4043;
--border-color: #e0e0e0;
--shadow-soft: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-inner: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
--success: #34a853;
--danger: #ea4335;
--warning: #fbbc04;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #ffffff;
color: var(--text-light);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
button {
cursor: pointer;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
}
input,
textarea {
background-color: var(--surface);
border: 1px solid var(--border-color);
color: var(--text-light);
padding: 12px 16px;
border-radius: 12px;
font-size: 16px;
transition: all 0.25s ease;
box-shadow: var(--shadow-inner);
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(11, 87, 208, 0.12);
background-color: var(--surface);
}
input::placeholder,
textarea::placeholder {
color: var(--text-muted);
}

View File

@ -0,0 +1,11 @@
import React from "react"
import ReactDOM from "react-dom/client"
import "./index.css"
import App from "./App"
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,159 @@
"use client"
import { useState, useEffect } from "react"
import { userAPI } from "../api/auth"
import Navbar from "../components/Navbar"
import "../styles/AdminDashboard.css"
export default function AdminDashboardPage() {
const [stats, setStats] = useState({ totalUsers: 0, pendingUsers: 0, approvedUsers: 0 })
const [pendingUsers, setPendingUsers] = useState([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState({})
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const [statsRes, usersRes] = await Promise.all([userAPI.getStats(), userAPI.getPendingUsers()])
setStats(statsRes.data)
setPendingUsers(usersRes.data)
} catch (error) {
console.error("Error fetching data:", error)
} finally {
setLoading(false)
}
}
const handleApprove = async (userId) => {
setActionLoading((prev) => ({ ...prev, [userId]: true }))
try {
await userAPI.approveUser(userId)
setPendingUsers((prev) => prev.filter((u) => u._id !== userId))
setStats((prev) => ({
...prev,
pendingUsers: prev.pendingUsers - 1,
approvedUsers: prev.approvedUsers + 1,
}))
} catch (error) {
console.error("Error approving user:", error)
} finally {
setActionLoading((prev) => ({ ...prev, [userId]: false }))
}
}
const handleDelete = async (userId) => {
if (window.confirm("Bạn có chắc chắn muốn xóa người dùng này?")) {
setActionLoading((prev) => ({ ...prev, [userId]: true }))
try {
await userAPI.deleteUser(userId)
setPendingUsers((prev) => prev.filter((u) => u._id !== userId))
setStats((prev) => ({
...prev,
totalUsers: prev.totalUsers - 1,
pendingUsers: prev.pendingUsers - 1,
}))
} catch (error) {
console.error("Error deleting user:", error)
} finally {
setActionLoading((prev) => ({ ...prev, [userId]: false }))
}
}
}
return (
<div className="admin-dashboard">
<Navbar />
<div className="admin-container">
<div className="admin-header">
<h1>Bảng điều khiển Admin</h1>
<p>Quản người dùng duyệt đăng </p>
</div>
{/* Stats Cards */}
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon total">👥</div>
<div className="stat-content">
<p className="stat-label">Tổng người dùng</p>
<h3 className="stat-value">{stats.totalUsers}</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon pending"></div>
<div className="stat-content">
<p className="stat-label">Chờ duyệt</p>
<h3 className="stat-value">{stats.pendingUsers}</h3>
</div>
</div>
<div className="stat-card">
<div className="stat-icon approved"></div>
<div className="stat-content">
<p className="stat-label">Đã duyệt</p>
<h3 className="stat-value">{stats.approvedUsers}</h3>
</div>
</div>
</div>
{/* Pending Users Table */}
<div className="pending-users-section">
<h2>Người dùng chờ duyệt</h2>
{loading ? (
<div className="loading">Đang tải...</div>
) : pendingUsers.length === 0 ? (
<div className="empty-state">
<p>Không người dùng nào chờ duyệt</p>
</div>
) : (
<div className="table-wrapper">
<table className="users-table">
<thead>
<tr>
<th>Email</th>
<th>Họ tên</th>
<th>Số điện thoại</th>
<th>Ngày đăng </th>
<th>Hành động</th>
</tr>
</thead>
<tbody>
{pendingUsers.map((pendingUser) => (
<tr key={pendingUser._id}>
<td className="email-cell">{pendingUser.email}</td>
<td>{pendingUser.fullName}</td>
<td>{pendingUser.phone || "-"}</td>
<td>{new Date(pendingUser.createdAt).toLocaleDateString("vi-VN")}</td>
<td className="action-cell">
<button
className="btn-approve"
onClick={() => handleApprove(pendingUser._id)}
disabled={actionLoading[pendingUser._id]}
>
{actionLoading[pendingUser._id] ? "..." : "✓ Duyệt"}
</button>
<button
className="btn-delete"
onClick={() => handleDelete(pendingUser._id)}
disabled={actionLoading[pendingUser._id]}
>
{actionLoading[pendingUser._id] ? "..." : "✕ Xóa"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,260 @@
"use client"
import { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { useAuth } from "../hooks/useAuth"
import { meetingAPI, documentAPI } from "../api/auth"
import Navbar from "../components/Navbar"
import "../styles/Dashboard.css"
export default function DashboardPage() {
const navigate = useNavigate()
const { user } = useAuth()
const [meetings, setMeetings] = useState([])
const [loading, setLoading] = useState(true)
const [showCreateForm, setShowCreateForm] = useState(false)
const [formData, setFormData] = useState({
title: "",
description: "",
})
const [selectedFile, setSelectedFile] = useState(null)
const [uploading, setUploading] = useState(false)
const [uploadError, setUploadError] = useState(null)
useEffect(() => {
fetchMeetings()
}, [])
const fetchMeetings = async () => {
try {
const response = await meetingAPI.getMeetings()
setMeetings(response.data)
} catch (error) {
console.error("Error fetching meetings:", error)
} finally {
setLoading(false)
}
}
const handleCreateMeeting = async (e) => {
e.preventDefault()
setUploading(true)
setUploadError(null)
try {
// Create meeting first
const response = await meetingAPI.createMeeting(formData)
const newMeeting = response.data.meeting
// If file is selected, upload it after meeting is created
if (selectedFile && newMeeting?.roomId) {
try {
const formDataUpload = new FormData()
formDataUpload.append("document", selectedFile)
formDataUpload.append("roomId", newMeeting.roomId)
await documentAPI.uploadDocument(formDataUpload)
console.log("Document uploaded successfully")
} catch (uploadErr) {
console.error("Error uploading document:", uploadErr)
setUploadError("Cuộc họp đã được tạo nhưng upload tài liệu thất bại: " + (uploadErr.response?.data?.message || uploadErr.message))
}
}
// Reset form
setFormData({ title: "", description: "" })
setSelectedFile(null)
setShowCreateForm(false)
fetchMeetings()
} catch (error) {
console.error("Error creating meeting:", error)
setUploadError(error.response?.data?.message || "Lỗi khi tạo cuộc họp")
} finally {
setUploading(false)
}
}
const handleChange = (e) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
const handleFileSelect = (e) => {
const file = e.target.files[0]
if (file) {
// Validate file type
const allowedTypes = [
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
"text/plain",
]
if (!allowedTypes.includes(file.type)) {
setUploadError("Chỉ hỗ trợ file PDF, DOCX, DOC, TXT (tối đa 10MB)")
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setUploadError("File quá lớn. Tối đa 10MB")
return
}
setSelectedFile(file)
setUploadError(null)
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB"
return (bytes / (1024 * 1024)).toFixed(2) + " MB"
}
return (
<div className="dashboard">
<Navbar />
<div className="dashboard-container">
<div className="dashboard-header">
<h1>Chào mừng, {user?.fullName || user?.email}!</h1>
<p>Quản các cuộc họp của bạn</p>
</div>
{user?.role === "admin" && (
<div className="admin-section">
<button className="btn-create-meeting" onClick={() => setShowCreateForm(!showCreateForm)}>
+ Tạo cuộc họp mới
</button>
{showCreateForm && (
<form onSubmit={handleCreateMeeting} className="create-meeting-form">
<div className="form-group">
<label>Tiêu đề cuộc họp</label>
<input
type="text"
name="title"
placeholder="Nhập tiêu đề cuộc họp"
value={formData.title}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label> tả</label>
<textarea
name="description"
placeholder="Nhập mô tả cuộc họp"
value={formData.description}
onChange={handleChange}
rows="4"
/>
</div>
<div className="form-group">
<label>📄 Tài liệu (Tùy chọn)</label>
<div className="file-upload-section">
<input
type="file"
id="meeting-document-input"
accept=".pdf,.doc,.docx,.txt"
onChange={handleFileSelect}
className="file-input"
/>
<label htmlFor="meeting-document-input" className="file-upload-label">
{selectedFile ? (
<div className="file-selected-info">
<span className="file-icon">📄</span>
<div className="file-details">
<span className="file-name">{selectedFile.name}</span>
<span className="file-size">{formatFileSize(selectedFile.size)}</span>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setSelectedFile(null)
const fileInput = document.getElementById("meeting-document-input")
if (fileInput) fileInput.value = ""
}}
className="btn-remove-file"
>
</button>
</div>
) : (
<div className="file-upload-placeholder">
<span className="upload-icon">📤</span>
<span>Chọn tài liệu để upload (PDF, DOCX, DOC, TXT - tối đa 10MB)</span>
</div>
)}
</label>
</div>
{uploadError && <div className="form-error">{uploadError}</div>}
</div>
<div className="form-actions">
<button type="submit" className="btn-primary" disabled={uploading}>
{uploading ? "Đang tạo..." : "Tạo cuộc họp"}
</button>
<button
type="button"
className="btn-cancel"
onClick={() => {
setShowCreateForm(false)
setSelectedFile(null)
setUploadError(null)
const fileInput = document.getElementById("meeting-document-input")
if (fileInput) fileInput.value = ""
}}
disabled={uploading}
>
Hủy
</button>
</div>
</form>
)}
</div>
)}
<div className="meetings-section">
<h2>Danh sách cuộc họp</h2>
{loading ? (
<div className="loading">Đang tải...</div>
) : meetings.length === 0 ? (
<div className="empty-state">
<p>Chưa cuộc họp nào</p>
</div>
) : (
<div className="meetings-grid">
{meetings.map((meeting) => (
<div key={meeting._id} className="meeting-card">
<h3>{meeting.title}</h3>
<p className="meeting-description">{meeting.description}</p>
<div className="meeting-meta">
<span className="meeting-creator">Tạo bởi: {meeting.createdBy?.fullName || meeting.createdBy?.email}</span>
<span className="meeting-date">{new Date(meeting.createdAt).toLocaleDateString("vi-VN")}</span>
</div>
<button
className="btn-join"
onClick={() => {
if (meeting.roomId) {
navigate(`/meeting/${meeting.roomId}`)
}
}}
>
Tham gia cuộc họp
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,151 @@
"use client"
import { useState } from "react"
import { useNavigate, Link } from "react-router-dom"
import { useAuth } from "../hooks/useAuth"
import { authAPI } from "../api/auth"
import { GoogleLogin } from "@react-oauth/google"
import "../styles/AuthPages.css"
export default function LoginPage() {
const navigate = useNavigate()
const { login } = useAuth()
const [formData, setFormData] = useState({
email: "",
password: "",
})
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleChange = (e) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError("")
setLoading(true)
try {
const response = await authAPI.login({
email: formData.email,
password: formData.password,
})
const userData = {
...response.data.user,
role: response.data.role,
}
login(response.data.token, userData)
if (response.data.role === "admin") {
navigate("/admin")
} else {
navigate("/dashboard")
}
} catch (err) {
setError(err.response?.data?.message || "Đăng nhập thất bại")
} finally {
setLoading(false)
}
}
const handleGoogleSuccess = async (credentialResponse) => {
try {
const token = credentialResponse.credential
const base64Url = token.split(".")[1]
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/")
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join(""),
)
const googleData = JSON.parse(jsonPayload)
const response = await authAPI.googleCallback({
googleId: googleData.sub,
email: googleData.email,
fullName: googleData.name,
})
const userData = {
...response.data.user,
role: response.data.role,
}
login(response.data.token, userData)
if (response.data.role === "admin") {
navigate("/admin")
} else {
navigate("/dashboard")
}
} catch (err) {
setError(err.response?.data?.message || "Đăng nhập Google thất bại")
}
}
return (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">Chào mừng bạn trở lại!</h1>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label>Email</label>
<input
type="email"
name="email"
placeholder="Nhập email của bạn"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label>Mật khẩu</label>
<input
type="password"
name="password"
placeholder="Nhập mật khẩu của bạn"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Đang xử lý..." : "Đăng nhập"}
</button>
</form>
<p className="forgot-password">
<Link to="/forgot-password">Quên mật khẩu?</Link>
</p>
<div className="divider">
<span>Hoặc tiếp tục với</span>
</div>
<div className="google-login">
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={() => setError("Đăng nhập Google thất bại")}
text="signin_with"
locale="vi_VN"
/>
</div>
<p className="auth-footer">
Quên mật khẩu? <Link to="/register">Đăng ngay</Link>
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,998 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { io } from "socket.io-client"
import { useAuth } from "../hooks/useAuth"
import { documentAPI, minutesAPI } from "../api/auth"
import client from "../api/client"
import Navbar from "../components/Navbar"
import VideoCall from "../components/VideoCall"
import DocumentUpload from "../components/DocumentUpload"
import RAGChatbox from "../components/RAGChatbox"
import "../styles/MeetingRoom.css"
import "../styles/VideoCall.css"
import "../styles/DocumentUpload.css"
import "../styles/RAGChatbox.css"
import "../styles/Documents.css"
export default function MeetingRoomPage() {
const { roomId } = useParams()
const navigate = useNavigate()
const { user, token } = useAuth()
const [meeting, setMeeting] = useState(null)
const [participants, setParticipants] = useState([])
const [messages, setMessages] = useState([])
const [messageInput, setMessageInput] = useState("")
const [typingUsers, setTypingUsers] = useState([])
const [chatMode, setChatMode] = useState("public") // "public" or "private"
const [selectedUser, setSelectedUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showVideoCall, setShowVideoCall] = useState(false)
const [documents, setDocuments] = useState([])
const [activeTab, setActiveTab] = useState("live") // 'meetings' | 'chat' | 'live' | 'documents' | 'vote' | 'whiteboard' | 'minutes' | 'broadcast'
const [meetingsList, setMeetingsList] = useState([])
const [selectedDocument, setSelectedDocument] = useState(null)
const [isVideoEnabled, setIsVideoEnabled] = useState(true)
const [isAudioEnabled, setIsAudioEnabled] = useState(true)
const [meetingMinutes, setMeetingMinutes] = useState([])
const [splitPosition, setSplitPosition] = useState(50) // Percentage for split view
const [isDragging, setIsDragging] = useState(false)
const socketRef = useRef(null)
const initializedRef = useRef(false)
const messagesEndRef = useRef(null)
const typingTimeoutRef = useRef(null)
const seenMessageIdsRef = useRef(new Set())
const splitContainerRef = useRef(null)
useEffect(() => {
if (initializedRef.current) return
initializedRef.current = true
initializeMeeting()
return () => {
if (socketRef.current) {
try {
socketRef.current.removeAllListeners?.()
} catch {}
socketRef.current.disconnect()
}
initializedRef.current = false
}
}, [roomId, token]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
scrollToBottom()
}, [messages])
// Handle split view drag
useEffect(() => {
const handleMouseMove = (e) => {
if (!isDragging || !splitContainerRef.current) return
const container = splitContainerRef.current
const rect = container.getBoundingClientRect()
const x = e.clientX - rect.left
const percentage = (x / rect.width) * 100
// Limit between 20% and 80%
const clampedPercentage = Math.max(20, Math.min(80, percentage))
setSplitPosition(clampedPercentage)
}
const handleMouseUp = () => {
setIsDragging(false)
}
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [isDragging])
const initializeMeeting = async () => {
try {
// Lấy thông tin meeting theo roomId với token hiện tại; nếu không có, thử coi tham số là _id và redirect
let response
try {
response = await client.get(`/meetings/room/${roomId}`, {
headers: { Authorization: `Bearer ${token}` },
})
} catch (e) {
// Nếu 404, thử lấy theo _id
try {
const byId = await client.get(`/meetings/${roomId}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (byId?.data?.roomId) {
navigate(`/meeting/${byId.data.roomId}`, { replace: true })
return
}
} catch (_) {}
throw e
}
setMeeting(response.data)
// Kết nối Socket.IO
const API_URL = process.env.REACT_APP_API_URL || "https://bkmeeting.soict.io"
const socket = io(API_URL.replace("/api", ""), {
auth: {
token: token,
},
transports: ["websocket", "polling"],
})
socketRef.current = socket
// Xử lý kết nối
socket.on("connect", () => {
console.log("Connected to server")
// Tham gia meeting room
socket.emit("join-meeting", {
roomId: roomId,
})
})
socket.on("connect_error", (err) => {
console.error("Connection error:", err)
setError("Không thể kết nối đến server")
})
// Đã tham gia meeting thành công
socket.on("joined-meeting", (data) => {
console.log("Joined meeting:", data)
setLoading(false)
// Auto enter video call mode when joining
setShowVideoCall(true)
setActiveTab("live")
})
// Nhận danh sách participants hiện tại
socket.on("current-participants", (data) => {
setParticipants(data.participants)
})
// User mới tham gia
socket.on("user-joined", (data) => {
setParticipants(data.participants)
addSystemMessage(`${data.user.name} đã tham gia cuộc họp`)
})
// User rời khỏi
socket.on("user-left", (data) => {
setParticipants(data.participants)
addSystemMessage(`${data.user.name} đã rời khỏi cuộc họp`)
})
// Nhận tin nhắn chat (đảm bảo không đăng ký trùng listener)
socket.off("chat-message")
socket.on("chat-message", (data) => {
if (data?.id) {
if (seenMessageIdsRef.current.has(data.id)) return
seenMessageIdsRef.current.add(data.id)
}
setMessages((prev) => [...prev, data])
})
// Typing indicator (đảm bảo không đăng ký trùng listener)
socket.off("typing")
socket.on("typing", (data) => {
if (data.isTyping) {
setTypingUsers((prev) => {
if (!prev.find((u) => u.userId === data.userId)) {
return [...prev, { userId: data.userId, userName: data.userName }]
}
return prev
})
} else {
setTypingUsers((prev) => prev.filter((u) => u.userId !== data.userId))
}
})
// Lỗi
socket.on("error", (error) => {
setError(error.message)
})
// Cuộc họp bị kết thúc bởi admin
socket.on("meeting-ended", (data) => {
alert(`Cuộc họp đã được kết thúc bởi ${data.endedBy?.name || "admin"}. Bạn sẽ được chuyển về trang chủ.`)
handleLeaveMeeting()
})
setLoading(false)
} catch (err) {
console.error("Error initializing meeting:", err)
setError("Không thể tải thông tin cuộc họp")
setLoading(false)
}
}
// Fetch documents for meeting
const fetchDocuments = async () => {
try {
const response = await documentAPI.getDocuments(roomId)
setDocuments(response.data)
} catch (error) {
console.error("Error fetching documents:", error)
}
}
useEffect(() => {
if (roomId && activeTab === "documents") {
fetchDocuments()
}
}, [roomId, activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
// Fetch meeting minutes
const fetchMeetingMinutes = async () => {
try {
const response = await minutesAPI.getMinutes(roomId)
setMeetingMinutes(response.data)
} catch (error) {
console.error("Error fetching meeting minutes:", error)
}
}
useEffect(() => {
if (roomId && activeTab === "minutes") {
fetchMeetingMinutes()
}
}, [roomId, activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
// Handle recording uploaded callback
const handleRecordingUploaded = () => {
fetchMeetingMinutes()
}
const handleDocumentUploadSuccess = () => {
fetchDocuments()
}
// Fetch meetings for Meetings tab
const fetchMeetingsList = async () => {
try {
const res = await client.get("/meetings", { headers: { Authorization: `Bearer ${token}` } })
setMeetingsList(res.data)
} catch (e) {
console.error("Error fetching meetings list:", e)
}
}
const addSystemMessage = (text) => {
setMessages((prev) => [
...prev,
{
userId: "system",
userName: "Hệ thống",
message: text,
timestamp: new Date(),
type: "system",
},
])
}
const handleSendMessage = (e) => {
e.preventDefault()
if (!messageInput.trim() || !socketRef.current) return
const messageData = {
roomId: roomId,
message: messageInput.trim(),
messageType: chatMode,
targetUserId: chatMode === "private" ? selectedUser?.userId : null,
}
socketRef.current.emit("chat-message", messageData)
setMessageInput("")
// Clear typing indicator
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
socketRef.current.emit("typing", {
roomId: roomId,
isTyping: false,
})
}
const handleTyping = (e) => {
setMessageInput(e.target.value)
if (!socketRef.current) return
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
// Emit typing indicator
socketRef.current.emit("typing", {
roomId: roomId,
isTyping: true,
})
// Clear typing after 3 seconds
typingTimeoutRef.current = setTimeout(() => {
socketRef.current?.emit("typing", {
roomId: roomId,
isTyping: false,
})
}, 3000)
}
const handleLeaveMeeting = () => {
if (socketRef.current) {
socketRef.current.emit("leave-meeting", { roomId })
socketRef.current.disconnect()
}
navigate("/dashboard")
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}
const formatTime = (timestamp) => {
const date = new Date(timestamp)
return date.toLocaleTimeString("vi-VN", { hour: "2-digit", minute: "2-digit" })
}
if (loading) {
return (
<div className="meeting-room">
<Navbar />
<div className="meeting-loading">
<div className="loading-spinner"></div>
<p>Đang kết nối đến cuộc họp...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="meeting-room">
<Navbar />
<div className="meeting-error">
<p>{error}</p>
<button onClick={() => navigate("/dashboard")} className="btn-primary">
Quay lại
</button>
</div>
</div>
)
}
return (
<div className={`meeting-room ${activeTab === "live" || showVideoCall ? "live-fullscreen" : ""}`}>
{!showVideoCall && activeTab !== "live" && <Navbar />}
<div className={`meeting-room-container ${activeTab === "live" || showVideoCall ? "live-fullscreen" : ""}`}>
{/* Header - Only show when not in meeting (before joining) */}
{!showVideoCall && activeTab !== "live" && (
<div className="meeting-header">
<div className="meeting-info">
<h1>{meeting?.title}</h1>
<p>{meeting?.description}</p>
<div className="meeting-meta">
<span className="participant-count">
<span className="count-badge">{participants.length}</span> ngưi tham gia
</span>
<span className="room-id">Room ID: {roomId}</span>
</div>
</div>
<button onClick={handleLeaveMeeting} className="btn-leave">
Rời cuộc họp
</button>
</div>
)}
{/* Compact header when in meeting - removed to avoid overlap */}
{/* Tabs - Hide when in meeting */}
{!showVideoCall && activeTab !== "live" && (
<div className="tabs-bar">
<button
className={`tab-btn ${activeTab === "meetings" ? "active" : ""}`}
onClick={() => {
setActiveTab("meetings")
fetchMeetingsList()
setShowVideoCall(false)
}}
>
Cuộc họp
</button>
<button
className={`tab-btn ${activeTab === "live" ? "active" : ""}`}
onClick={() => {
setActiveTab("live")
setShowVideoCall(true)
}}
>
Trực tuyến
</button>
<button
className={`tab-btn ${activeTab === "chat" ? "active" : ""}`}
onClick={() => {
setActiveTab("chat")
setShowVideoCall(false)
}}
>
Trao đổi
</button>
<button
className={`tab-btn ${activeTab === "documents" ? "active" : ""}`}
onClick={() => {
setActiveTab("documents")
setShowVideoCall(false)
fetchDocuments()
}}
>
Tài liệu
</button>
<button
className={`tab-btn ${activeTab === "minutes" ? "active" : ""}`}
onClick={() => {
setActiveTab("minutes")
setShowVideoCall(false)
}}
>
Biên bản họp
</button>
</div>
)}
<div className={`meeting-content ${activeTab === "live" || showVideoCall ? "live-fullscreen" : ""}`}>
{/* Left Navigation Bar */}
<div className={`meeting-nav-bar ${activeTab === "live" || showVideoCall ? "live-mode" : ""}`}>
<button
className={`nav-bar-btn ${activeTab === "live" ? "active" : ""}`}
onClick={() => {
setActiveTab("live")
setShowVideoCall(true)
}}
title="Trực tuyến"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" fill="currentColor"/>
</svg>
<span>Trực tuyến</span>
</button>
<button
className={`nav-bar-btn ${activeTab === "chat" ? "active" : ""}`}
onClick={() => {
setActiveTab("chat")
setShowVideoCall(true)
}}
title="Trao đổi"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" fill="currentColor"/>
</svg>
<span>Trao đổi</span>
</button>
<button
className={`nav-bar-btn ${activeTab === "documents" ? "active" : ""}`}
onClick={() => {
setActiveTab("documents")
setShowVideoCall(true)
fetchDocuments()
}}
title="Tài liệu"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill="currentColor"/>
</svg>
<span>Tài liệu</span>
</button>
<button
className={`nav-bar-btn ${activeTab === "minutes" ? "active" : ""}`}
onClick={() => {
setActiveTab("minutes")
setShowVideoCall(true)
}}
title="Biên bản họp"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" fill="currentColor"/>
</svg>
<span>Biên bản họp</span>
</button>
<div className="nav-bar-divider"></div>
<div className="nav-bar-user-avatar">
{user?.fullName?.charAt(0) || user?.email?.charAt(0) || "U"}
</div>
</div>
{/* Sidebar - Participants & Chat Mode */}
{activeTab !== "live" && !showVideoCall && (
<div className="meeting-sidebar">
<div className="sidebar-section">
<h3>Thành viên ({participants.length})</h3>
<div className="participants-list">
{participants.map((participant) => (
<div
key={participant.userId}
className={`participant-item ${selectedUser?.userId === participant.userId ? "selected" : ""}`}
onClick={() => {
if (participant.userId !== user?.id) {
setSelectedUser(participant)
setChatMode("private")
}
}}
>
<div className="participant-avatar">
{participant.userName.charAt(0).toUpperCase()}
</div>
<div className="participant-info">
<span className="participant-name">
{participant.userName}
{participant.userId === user?.id && " (Bạn)"}
</span>
</div>
</div>
))}
</div>
</div>
<div className="sidebar-section">
<h3>Chế độ chat</h3>
<div className="chat-mode-selector">
<button
className={`chat-mode-btn ${chatMode === "public" ? "active" : ""}`}
onClick={() => {
setChatMode("public")
setSelectedUser(null)
}}
>
💬 Tất cả
</button>
<button
className={`chat-mode-btn ${chatMode === "private" ? "active" : ""}`}
onClick={() => {
if (selectedUser) {
setChatMode("private")
}
}}
disabled={!selectedUser}
>
🔒 Riêng
{selectedUser && `: ${selectedUser.userName}`}
</button>
</div>
</div>
</div>
)}
{/* Main Area (switch by tab) */}
{showVideoCall ? (
<div className="split-view-container" ref={splitContainerRef}>
{/* Left Panel - only when not in live tab */}
{activeTab !== "live" && (
<div className="split-view-left" style={{ width: `${splitPosition}%` }}>
<div className="meeting-chat">
{activeTab === "meetings" && (
<>
<div className="chat-header"><h3>📋 Danh sách cuộc họp</h3></div>
<div className="meetings-grid">
{meetingsList.map((m) => (
<div key={m._id} className="meeting-card">
<h3>{m.title}</h3>
<p className="meeting-description">{m.description}</p>
<div className="meeting-meta">
<span>Tạo bởi: {m.createdBy?.fullName || m.createdBy?.email}</span>
<span>{new Date(m.createdAt).toLocaleDateString("vi-VN")}</span>
</div>
<button
className="btn-join"
onClick={() => {
if (m.roomId) navigate(`/meeting/${m.roomId}`)
}}
>
Tham gia
</button>
</div>
))}
</div>
</>
)}
{activeTab === "chat" && (
<div className="chat-layout">
{/* Left Panel - Chat List */}
<div className="chat-list-panel">
<div className="chat-list-header">
<h3>Chat</h3>
</div>
<div className="chat-list-tabs">
<button
className={`chat-list-tab ${chatMode === "public" ? "active" : ""}`}
onClick={() => {
setChatMode("public")
setSelectedUser(null)
}}
>
Chat chung
</button>
<button
className={`chat-list-tab ${chatMode === "private" ? "active" : ""}`}
onClick={() => {
if (selectedUser) {
setChatMode("private")
}
}}
disabled={!selectedUser}
>
Chat riêng
</button>
</div>
{chatMode === "public" && (
<div className="chat-list-info">
<div className="chat-list-icon">👥</div>
<span>{participants.length} thành viên</span>
</div>
)}
{chatMode === "private" && selectedUser && (
<div className="chat-list-selected">
<div className="chat-list-icon">🔒</div>
<span>Chat với {selectedUser.userName}</span>
</div>
)}
<div className="chat-private-section">
<div className="chat-private-title">Thành viên</div>
{participants.filter((p) => p.userId !== user?.id).length > 0 ? (
<div className="chat-private-list">
{participants
.filter((p) => p.userId !== user?.id)
.map((participant) => (
<button
type="button"
key={participant.userId}
className={`chat-private-item ${selectedUser?.userId === participant.userId ? "selected" : ""}`}
onClick={() => {
setSelectedUser(participant)
setChatMode("private")
}}
>
<div className="chat-private-avatar">
{participant.userName.charAt(0).toUpperCase()}
</div>
<div className="chat-private-info">
<span className="chat-private-name">{participant.userName}</span>
</div>
{selectedUser?.userId === participant.userId && (
<span className="chat-private-badge">Đang chọn</span>
)}
</button>
))}
</div>
) : (
<div className="chat-private-empty">Chưa thành viên khác để chat riêng</div>
)}
</div>
</div>
{/* Right Panel - Messages */}
<div className="chat-messages-panel">
<div className="chat-header">
<h3>
{chatMode === "public" ? "Chat chung" : `Chat riêng với ${selectedUser?.userName}`}
</h3>
</div>
<div className="messages-container" ref={messagesEndRef}>
{messages.map((msg, index) => {
const isOwnMessage = msg.userId === user?.id
const isSystemMessage = msg.type === "system"
const isPrivateMessage = msg.type === "private"
return (
<div
key={index}
className={`message ${isOwnMessage ? "own" : ""} ${isSystemMessage ? "system" : ""} ${isPrivateMessage ? "private" : ""}`}
>
{!isSystemMessage && (
<div className="message-avatar">
{msg.userName.charAt(0).toUpperCase()}
</div>
)}
<div className="message-content">
{!isSystemMessage && (
<div className="message-header">
<span className="message-author">{msg.userName}</span>
{isPrivateMessage && (
<span className="private-badge">
{isOwnMessage && msg.targetUserId ? "🔒 Gửi riêng" : "🔒 Nhận riêng"}
</span>
)}
<span className="message-time">{formatTime(msg.timestamp)}</span>
</div>
)}
<div className="message-text">{msg.message}</div>
</div>
</div>
)
})}
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.map((typingUser) => (
<span key={typingUser.userId}>{typingUser.userName} đang ...</span>
))}
</div>
)}
</div>
<form onSubmit={handleSendMessage} className="chat-input-form">
<button type="button" className="chat-mention-btn" title="Mention">
@
</button>
<input
type="text"
value={messageInput}
onChange={handleTyping}
placeholder={chatMode === "public" ? "Nhập tin nhắn vào nhóm..." : `Nhập tin nhắn cho ${selectedUser?.userName}...`}
className="chat-input"
disabled={chatMode === "private" && !selectedUser}
/>
<button type="submit" className="btn-send" disabled={!messageInput.trim()}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" fill="currentColor"/>
</svg>
</button>
</form>
</div>
</div>
)}
{activeTab === "documents" && (
<div className="documents-layout">
{/* Left Panel - Document Preview */}
<div className="documents-preview-panel">
<div className="documents-preview-header">
<h3>Xem trước</h3>
</div>
<div className="documents-preview-content">
{selectedDocument ? (
<div className="document-preview">
<div className="document-preview-icon">📄</div>
<div className="document-preview-name">{selectedDocument.originalName || selectedDocument.fileName}</div>
<div className="document-preview-info">
<p>Chưa file đính kèm nào được xem trước! Vui lòng tải lên hoặc chọn tài liệu bên!</p>
</div>
</div>
) : (
<div className="document-preview-empty">
<div className="document-preview-icon-large">📄</div>
<p>Chưa tài liệu được chọn</p>
<p className="document-preview-hint">Click vào tên file bên phải để xem</p>
</div>
)}
</div>
</div>
{/* Right Panel - Documents List */}
<div className="documents-list-panel">
<div className="documents-list-header">
<div className="documents-list-tabs">
<button className="documents-list-tab active">Văn bản chung</button>
<button className="documents-list-tab">Tài liệu nhân</button>
</div>
<div className="documents-search">
<input type="text" placeholder="Tìm kiếm văn bản" className="documents-search-input" />
<button className="documents-search-btn">🔍</button>
</div>
</div>
<div className="documents-content">
{/* Only show upload for admin */}
{user?.role === "admin" && (
<DocumentUpload roomId={roomId} onUploadSuccess={handleDocumentUploadSuccess} />
)}
{/* Documents List */}
<div className="documents-list">
{documents.length > 0 ? (
<div className="documents-list-items">
{documents.map((doc) => (
<div
key={doc._id}
className={`document-list-item ${selectedDocument?._id === doc._id ? "selected" : ""}`}
onClick={() => setSelectedDocument(doc)}
>
<div className="document-list-icon">📄</div>
<div className="document-list-info">
<div className="document-list-name" title={doc.originalName}>
{doc.originalName || doc.fileName || "Untitled"}
</div>
<div className="document-list-meta">
<span>{new Date(doc.createdAt).toLocaleDateString("vi-VN")}</span>
<span>{(doc.fileSize / 1024).toFixed(2)} KB</span>
</div>
</div>
{user?.role === "admin" && (
<button
onClick={async (e) => {
e.stopPropagation()
if (window.confirm("Bạn có chắc muốn xóa tài liệu này?")) {
try {
await documentAPI.deleteDocument(doc._id)
if (selectedDocument?._id === doc._id) {
setSelectedDocument(null)
}
fetchDocuments()
} catch (error) {
alert("Lỗi khi xóa tài liệu")
}
}
}}
className="document-list-delete"
title="Xóa tài liệu"
>
🗑
</button>
)}
</div>
))}
</div>
) : (
<div className="documents-empty">
<div className="documents-empty-icon">📁</div>
<p>Trống</p>
</div>
)}
</div>
{/* RAG Chatbox */}
<div className="rag-chatbox-container">
<RAGChatbox roomId={roomId} />
</div>
</div>
</div>
</div>
)}
{activeTab === "minutes" && (
<div className="minutes-layout">
<div className="minutes-header">
<h3>📝 Biên bản cuộc họp</h3>
<button onClick={fetchMeetingMinutes} className="btn-refresh" title="Làm mới">
🔄
</button>
</div>
<div className="minutes-list">
{meetingMinutes.length > 0 ? (
meetingMinutes.map((minute) => (
<div key={minute._id} className="minute-item">
<div className="minute-header">
<div className="minute-info">
<div className="minute-title">
Biên bản {new Date(minute.startTime).toLocaleString("vi-VN")}
</div>
<div className="minute-meta">
<span>Ghi bởi: {minute.recordedBy?.fullName || minute.recordedBy?.email || "N/A"}</span>
<span>Thời lượng: {minute.recordingDuration ? `${Math.floor(minute.recordingDuration / 60)}:${String(minute.recordingDuration % 60).padStart(2, "0")}` : "N/A"}</span>
<span className={`status-badge ${minute.transcriptionStatus}`}>
{minute.transcriptionStatus === "completed" ? "✓ Hoàn thành" :
minute.transcriptionStatus === "processing" ? "⏳ Đang xử lý" :
minute.transcriptionStatus === "error" ? "✗ Lỗi" : "⏸ Chờ xử lý"}
</span>
</div>
</div>
{user?.role === "admin" ? (
<button
onClick={async () => {
if (window.confirm("Bạn có chắc muốn xóa biên bản này?")) {
try {
await minutesAPI.deleteMinute(minute._id)
fetchMeetingMinutes()
} catch (error) {
alert("Lỗi khi xóa biên bản")
}
}
}}
className="btn-delete"
title="Xóa biên bản"
>
🗑
</button>
) : null}
</div>
<div className="minute-audio">
<audio controls src={minutesAPI.getAudioUrl(minute._id)}>
Trình duyệt của bạn không hỗ trợ phát audio.
</audio>
</div>
{minute.transcriptionStatus === "completed" && minute.transcription && (
<div className="minute-transcription">
<div className="transcription-header">📄 Văn bản chuyển đổi:</div>
<div className="transcription-content">
{minute.transcription.split('\n').map((line, idx) => (
<p key={idx}>{line || '\u00A0'}</p>
))}
</div>
</div>
)}
{minute.transcriptionStatus === "processing" && (
<div className="minute-transcription processing">
<div className="transcription-header"> Đang xử chuyển đổi văn bản...</div>
</div>
)}
{minute.transcriptionStatus === "error" && (
<div className="minute-transcription error">
<div className="transcription-header"> Lỗi: {minute.transcriptionError || "Không xác định"}</div>
</div>
)}
</div>
))
) : (
<div className="minutes-empty">
<div className="empty-icon">📝</div>
<p>Chưa biên bản nào</p>
<p className="hint">Sử dụng nút ghi hình trong cuộc họp để tạo biên bản</p>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
{/* Split Resizer - only when left panel is visible */}
{activeTab !== "live" && (
<div
className="split-resizer"
onMouseDown={(e) => {
e.preventDefault()
setIsDragging(true)
}}
>
<div className="split-resizer-handle"></div>
</div>
)}
{/* Right Panel - single VideoCall instance */}
<div
className="split-view-right"
style={{ width: activeTab === "live" ? "100%" : `${100 - splitPosition}%` }}
>
<VideoCall
participants={participants}
socketRef={socketRef}
roomId={roomId}
user={user}
isAudioEnabled={isAudioEnabled}
isVideoEnabled={isVideoEnabled}
onToggleAudio={() => setIsAudioEnabled(!isAudioEnabled)}
onToggleVideo={() => setIsVideoEnabled(!isVideoEnabled)}
onLeaveMeeting={handleLeaveMeeting}
onRecordingUploaded={handleRecordingUploaded}
/>
</div>
</div>
) : (
<div className={`meeting-chat`}>
<div className="tab-content-empty">
<div className="empty-icon">📹</div>
<h3>Chưa tham gia cuộc họp</h3>
<p>Nhấn "Trực tuyến" để bắt đầu cuộc họp</p>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,45 @@
import React, { useEffect, useState } from "react";
import { Container, Typography, Card, CardContent, Grid } from "@mui/material";
import axios from "axios";
const Meetings = () => {
const [meetings, setMeetings] = useState([]);
const token = localStorage.getItem("token");
useEffect(() => {
const fetchMeetings = async () => {
const res = await axios.get("https://bkmeeting.soict.io/api/meetings", {
headers: { Authorization: `Bearer ${token}` },
});
setMeetings(res.data);
};
fetchMeetings();
}, [token]);
return (
<Container sx={{ mt: 5 }}>
<Typography variant="h5" gutterBottom>
Danh sách cuộc họp
</Typography>
<Grid container spacing={2}>
{meetings.map((m) => (
<Grid item xs={12} md={6} key={m._id}>
<Card>
<CardContent>
<Typography variant="h6">{m.title}</Typography>
<Typography variant="body2" color="text.secondary">
{m.description}
</Typography>
<Typography variant="caption">
Tạo bởi: {m.createdBy?.username}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Container>
);
};
export default Meetings;

View File

@ -0,0 +1,169 @@
"use client"
import { useState } from "react"
import { useNavigate, Link } from "react-router-dom"
import { useAuth } from "../hooks/useAuth"
import { authAPI } from "../api/auth"
import { GoogleLogin } from "@react-oauth/google"
import "../styles/AuthPages.css"
export default function RegisterPage() {
const navigate = useNavigate()
const { login } = useAuth()
const [formData, setFormData] = useState({
email: "",
fullName: "",
phone: "",
password: "",
confirmPassword: "",
})
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const handleChange = (e) => {
const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value }))
}
const handleSubmit = async (e) => {
e.preventDefault()
setError("")
if (formData.password !== formData.confirmPassword) {
setError("Mật khẩu không khớp")
return
}
setLoading(true)
try {
const response = await authAPI.register({
email: formData.email,
fullName: formData.fullName,
phone: formData.phone,
password: formData.password,
})
setError("")
alert(response.data.message)
navigate("/login")
} catch (err) {
setError(err.response?.data?.message || "Đăng ký thất bại")
} finally {
setLoading(false)
}
}
const handleGoogleSuccess = async (credentialResponse) => {
try {
// Decode JWT token from Google
const token = credentialResponse.credential
const base64Url = token.split(".")[1]
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/")
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join(""),
)
const googleData = JSON.parse(jsonPayload)
const response = await authAPI.googleCallback({
googleId: googleData.sub,
email: googleData.email,
fullName: googleData.name,
})
login(response.data.token, response.data.user)
navigate("/dashboard")
} catch (err) {
setError(err.response?.data?.message || "Đăng nhập Google thất bại")
}
}
return (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">Đăng ngay!</h1>
<p className="auth-subtitle">Nhập các thông tin dưới đây để đăng tài khoản.</p>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit} className="auth-form">
<div className="form-group">
<label>Email</label>
<input
type="email"
name="email"
placeholder="your.email@example.com"
value={formData.email}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label>Họ tên</label>
<input
type="text"
name="fullName"
placeholder="Nguyễn Văn A"
value={formData.fullName}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label>Số điện thoại</label>
<input type="tel" name="phone" placeholder="+1234567890" value={formData.phone} onChange={handleChange} />
</div>
<div className="form-group">
<label>Mật khẩu</label>
<input
type="password"
name="password"
placeholder="••••••••"
value={formData.password}
onChange={handleChange}
required
/>
</div>
<div className="form-group">
<label>Xác nhận mật khẩu</label>
<input
type="password"
name="confirmPassword"
placeholder="••••••••"
value={formData.confirmPassword}
onChange={handleChange}
required
/>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Đang xử lý..." : "Tạo tài khoản"}
</button>
</form>
<div className="divider">
<span>Hoặc tiếp tục với</span>
</div>
<div className="google-login">
<GoogleLogin
onSuccess={handleGoogleSuccess}
onError={() => setError("Đăng nhập Google thất bại")}
text="signup_with"
locale="vi_VN"
/>
</div>
<p className="auth-footer">
Đã tài khoản? <Link to="/login">Đăng nhập ngay</Link>
</p>
</div>
</div>
)
}

View File

@ -0,0 +1,248 @@
.admin-dashboard {
min-height: 100vh;
background-color: #f5f5f5;
}
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 40px 24px;
}
.admin-header {
margin-bottom: 40px;
}
.admin-header h1 {
font-size: 32px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.admin-header p {
font-size: 16px;
color: #5f6368;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.stat-card {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
transition: all 0.3s ease;
box-shadow: var(--shadow-soft);
}
.stat-card:hover {
border-color: var(--primary);
background: #ffffff;
box-shadow: 0 4px 12px rgba(66, 133, 244, 0.15);
}
.stat-icon {
font-size: 32px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #fff3cd;
}
.stat-icon.total {
background: #e3f2fd;
}
.stat-icon.pending {
background: #ffe0b2;
}
.stat-icon.approved {
background: #c8e6c9;
}
.stat-content {
flex: 1;
}
.stat-label {
font-size: 13px;
color: #3c4043;
margin: 0 0 8px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 700;
}
.stat-value {
font-size: 36px;
font-weight: 700;
color: var(--primary);
margin: 0;
line-height: 1.2;
}
/* Pending Users Section */
.pending-users-section {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 32px;
box-shadow: var(--shadow-soft);
}
.pending-users-section h2 {
font-size: 24px;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 24px 0;
}
.table-wrapper {
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table thead {
background-color: #f5f5f5;
border-bottom: 2px solid var(--border-color);
}
.users-table th {
padding: 16px;
text-align: left;
font-size: 14px;
font-weight: 700;
color: #1a1a1a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table td {
padding: 16px;
border-bottom: 1px solid var(--border-color);
font-size: 14px;
color: #1a1a1a;
}
.users-table tbody tr:hover {
background-color: #f8f9fa;
}
.email-cell {
font-weight: 600;
color: var(--primary);
}
.action-cell {
display: flex;
gap: 8px;
}
.btn-approve,
.btn-delete {
padding: 8px 12px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.btn-approve {
background-color: rgba(76, 175, 80, 0.2);
color: var(--success);
border: 1px solid var(--success);
}
.btn-approve:hover:not(:disabled) {
background-color: rgba(76, 175, 80, 0.3);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.2);
}
.btn-delete {
background-color: rgba(244, 67, 54, 0.2);
color: var(--danger);
border: 1px solid var(--danger);
}
.btn-delete:hover:not(:disabled) {
background-color: rgba(244, 67, 54, 0.3);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
}
.btn-approve:disabled,
.btn-delete:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading,
.empty-state {
text-align: center;
padding: 60px 24px;
color: #3c4043;
font-size: 16px;
font-weight: 500;
}
.empty-state p {
margin: 0;
color: #3c4043;
}
@media (max-width: 768px) {
.admin-container {
padding: 24px 16px;
}
.admin-header h1 {
font-size: 24px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.pending-users-section {
padding: 20px;
}
.users-table th,
.users-table td {
padding: 12px 8px;
font-size: 12px;
}
.action-cell {
flex-direction: column;
gap: 4px;
}
.btn-approve,
.btn-delete {
padding: 6px 8px;
font-size: 11px;
}
}

View File

@ -0,0 +1,203 @@
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-darker) 0%, var(--bg-dark) 100%);
padding: 20px;
}
.auth-card {
background: rgba(10, 14, 39, 0.8);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 48px 40px;
width: 100%;
max-width: 420px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.auth-title {
font-size: 32px;
font-weight: 700;
color: var(--primary);
margin-bottom: 12px;
text-align: center;
}
.auth-subtitle {
font-size: 14px;
color: var(--text-muted);
text-align: center;
margin-bottom: 32px;
line-height: 1.5;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input {
padding: 12px 16px;
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-light);
font-size: 14px;
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
background-color: rgba(255, 215, 0, 0.08);
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.btn-primary {
background-color: var(--primary);
color: #000;
padding: 14px 24px;
font-size: 16px;
font-weight: 700;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-dark);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(255, 215, 0, 0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
background-color: rgba(244, 67, 54, 0.1);
border: 1px solid var(--danger);
color: #ff6b6b;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 20px;
}
.divider {
display: flex;
align-items: center;
gap: 16px;
margin: 24px 0;
color: var(--text-muted);
font-size: 14px;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
height: 1px;
background-color: var(--border-color);
}
.google-login {
display: flex;
justify-content: center;
margin-bottom: 24px;
}
.google-login button {
background-color: transparent;
border: 1px solid var(--border-color);
color: var(--text-light);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
}
.google-login button:hover {
border-color: var(--primary);
background-color: rgba(255, 215, 0, 0.05);
}
.forgot-password {
text-align: center;
margin-bottom: 24px;
}
.forgot-password a {
color: var(--primary);
text-decoration: none;
font-size: 14px;
font-weight: 600;
transition: color 0.3s ease;
}
.forgot-password a:hover {
color: var(--primary-dark);
}
.auth-footer {
text-align: center;
font-size: 14px;
color: var(--text-muted);
}
.auth-footer a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.auth-footer a:hover {
color: var(--primary-dark);
}
@media (max-width: 480px) {
.auth-card {
padding: 32px 24px;
}
.auth-title {
font-size: 24px;
}
.auth-subtitle {
font-size: 13px;
}
}

View File

@ -0,0 +1,371 @@
.dashboard {
min-height: 100vh;
background: radial-gradient(circle at 10% 20%, rgba(11, 87, 208, 0.08), transparent 45%), var(--bg-dark);
}
.dashboard-container {
max-width: 1360px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 32px;
}
.dashboard-header {
background: linear-gradient(135deg, #fefefe 5%, #f3f6fd 45%, #e8f0fe 100%);
border-radius: 32px;
padding: 36px;
box-shadow: var(--shadow-soft);
display: flex;
flex-direction: column;
gap: 12px;
}
.dashboard-header h1 {
font-size: 34px;
font-weight: 700;
color: #1a1a1a;
}
.dashboard-header p {
font-size: 16px;
color: #5f6368;
}
.admin-section {
background: var(--surface);
border: 1px solid rgba(223, 227, 235, 0.8);
border-radius: 24px;
padding: 28px;
box-shadow: var(--shadow-soft);
display: flex;
flex-direction: column;
gap: 20px;
}
.btn-create-meeting {
align-self: flex-start;
background: linear-gradient(135deg, #0b57d0, #7f39fb);
color: #fff;
padding: 14px 28px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
border: none;
box-shadow: 0 18px 30px rgba(11, 87, 208, 0.35);
transition: all 0.2s ease;
}
.btn-create-meeting:hover {
transform: translateY(-2px);
box-shadow: 0 22px 40px rgba(11, 87, 208, 0.4);
}
.create-meeting-form {
background-color: var(--surface-muted);
border: 1px solid rgba(223, 227, 235, 0.8);
border-radius: 20px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: #3c4043;
}
.form-group input,
.form-group textarea {
padding: 12px 16px;
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 16px;
color: #1a1a1a;
font-size: 14px;
transition: all 0.25s ease;
font-family: inherit;
box-shadow: var(--shadow-inner);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(11, 87, 208, 0.12);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 12px;
}
.btn-primary {
background: linear-gradient(135deg, #0b57d0, #1a73e8);
color: #fff;
padding: 14px 18px;
border-radius: 16px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
flex: 1;
box-shadow: 0 14px 30px rgba(26, 115, 232, 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(26, 115, 232, 0.35);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-cancel {
background-color: #ffffff;
border: 1px solid #e0e0e0;
color: #3c4043;
padding: 14px 18px;
border-radius: 16px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
flex: 1;
box-shadow: var(--shadow-inner);
}
.btn-cancel:hover:not(:disabled) {
border-color: var(--primary);
color: #1a1a1a;
}
.btn-cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.file-upload-section {
margin-top: 8px;
}
.file-input {
display: none;
}
.file-upload-label {
display: block;
cursor: pointer;
transition: all 0.3s ease;
}
.file-upload-placeholder {
display: flex;
align-items: center;
gap: 12px;
padding: 20px;
border: 2px dashed rgba(66, 133, 244, 0.2);
border-radius: 16px;
background: #ffffff;
color: #5f6368;
transition: all 0.3s ease;
text-align: center;
justify-content: center;
flex-wrap: wrap;
}
.file-upload-placeholder:hover {
border-color: var(--primary);
background: var(--primary-soft);
}
.upload-icon {
font-size: 24px;
}
.file-selected-info {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--primary-soft);
border-radius: 16px;
background: rgba(11, 87, 208, 0.05);
}
.file-icon {
font-size: 24px;
}
.file-details {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.file-name {
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
word-break: break-word;
}
.file-size {
font-size: 12px;
color: #5f6368;
}
.btn-remove-file {
background: transparent;
border: none;
color: #5f6368;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
transition: all 0.3s ease;
line-height: 1;
}
.btn-remove-file:hover {
background: rgba(217, 48, 37, 0.08);
color: var(--danger);
}
.form-error {
margin-top: 8px;
padding: 12px;
background: rgba(217, 48, 37, 0.08);
border: 1px solid rgba(217, 48, 37, 0.2);
border-radius: 12px;
color: var(--danger);
font-size: 14px;
}
.meetings-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.meetings-section h2 {
font-size: 22px;
font-weight: 700;
color: #1a1a1a;
}
.meetings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.meeting-card {
background: var(--surface);
border: 1px solid rgba(223, 227, 235, 0.9);
border-radius: 24px;
padding: 24px;
transition: all 0.25s ease;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: var(--shadow-soft);
}
.meeting-card:hover {
border-color: rgba(11, 87, 208, 0.25);
transform: translateY(-6px);
box-shadow: 0 25px 45px rgba(15, 23, 42, 0.15);
}
.meeting-card h3 {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
margin: 0;
}
.meeting-description {
font-size: 14px;
color: #5f6368;
margin: 0;
line-height: 1.5;
}
.meeting-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: #5f6368;
gap: 12px;
}
.meeting-meta span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-join {
background-color: var(--primary);
color: #fff;
padding: 12px 18px;
border-radius: 14px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-join:hover {
background-color: var(--primary-dark);
transform: translateY(-2px);
}
.loading,
.empty-state {
text-align: center;
padding: 60px 24px;
color: var(--text-muted);
font-size: 16px;
background: var(--surface);
border-radius: 24px;
border: 1px dashed rgba(223, 227, 235, 0.9);
}
@media (max-width: 768px) {
.dashboard-container {
padding: 24px 16px;
}
.dashboard-header h1 {
font-size: 24px;
}
.meetings-grid {
grid-template-columns: 1fr;
}
.admin-section,
.dashboard-header {
border-radius: 20px;
padding: 24px;
}
}

View File

@ -0,0 +1,134 @@
.document-upload {
background: var(--surface);
border: 1px solid rgba(223, 227, 235, 0.9);
border-radius: 24px;
padding: 24px;
margin-bottom: 20px;
box-shadow: var(--shadow-soft);
}
.upload-header h3 {
font-size: 18px;
font-weight: 700;
color: var(--text-light);
margin: 0 0 6px 0;
}
.upload-hint {
font-size: 13px;
color: var(--text-muted);
margin: 0 0 18px 0;
}
.upload-area {
margin-bottom: 16px;
}
.file-input {
display: none;
}
.file-label {
display: block;
cursor: pointer;
transition: all 0.3s ease;
}
.file-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 20px;
border: 2px dashed rgba(11, 87, 208, 0.2);
border-radius: 18px;
background: var(--surface-muted);
color: var(--text-muted);
transition: all 0.3s ease;
}
.file-placeholder:hover {
border-color: var(--primary);
background: var(--primary-soft);
color: var(--text-light);
}
.upload-icon {
font-size: 32px;
}
.file-selected {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border: 1px solid var(--primary-soft);
border-radius: 18px;
background: rgba(11, 87, 208, 0.08);
}
.file-icon {
font-size: 24px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.file-name {
font-size: 14px;
font-weight: 600;
color: var(--text-light);
}
.file-size {
font-size: 12px;
color: var(--text-muted);
}
.btn-upload {
width: 100%;
background: linear-gradient(135deg, #0b57d0, #1a73e8);
color: #fff;
padding: 14px 24px;
border-radius: 16px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 12px 24px rgba(11, 87, 208, 0.25);
}
.btn-upload:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-upload:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.upload-error,
.upload-success {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
font-size: 14px;
}
.upload-error {
background: rgba(217, 48, 37, 0.08);
border: 1px solid rgba(217, 48, 37, 0.2);
color: var(--danger);
}
.upload-success {
background: rgba(52, 168, 83, 0.08);
border: 1px solid rgba(52, 168, 83, 0.2);
color: var(--success);
}

View File

@ -0,0 +1,126 @@
.documents-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
overflow-y: auto;
}
.documents-list h4 {
font-size: 16px;
font-weight: 700;
color: var(--text-light);
margin: 0 0 16px 0;
}
.documents-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
background: var(--surface-muted);
border: 1px dashed rgba(223, 227, 235, 0.9);
border-radius: 18px;
}
.documents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.document-card {
background: var(--surface);
border: 1px solid rgba(223, 227, 235, 0.9);
border-radius: 20px;
padding: 18px;
display: flex;
align-items: flex-start;
gap: 12px;
transition: all 0.25s ease;
position: relative;
box-shadow: var(--shadow-soft);
}
.document-card:hover {
border-color: rgba(11, 87, 208, 0.2);
transform: translateY(-4px);
box-shadow: 0 18px 30px rgba(15, 23, 42, 0.18);
}
.document-clickable {
flex: 1;
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
transition: all 0.3s ease;
}
.document-icon {
font-size: 32px;
flex-shrink: 0;
}
.document-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.document-name {
font-size: 14px;
font-weight: 700;
color: var(--text-light);
word-break: break-word;
}
.document-meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.document-meta span {
display: block;
}
.btn-delete-doc {
background: transparent;
border: 1px solid var(--border-color);
color: var(--danger);
padding: 8px;
border-radius: 12px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
flex-shrink: 0;
}
.btn-delete-doc:hover {
background: rgba(217, 48, 37, 0.08);
border-color: var(--danger);
transform: scale(1.04);
}
.rag-chatbox-container {
height: 500px;
min-height: 500px;
}
@media (max-width: 768px) {
.documents-grid {
grid-template-columns: 1fr;
}
.rag-chatbox-container {
height: 400px;
min-height: 400px;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,204 @@
.navbar {
background: var(--surface-glass);
border-bottom: 1px solid rgba(223, 227, 235, 0.7);
padding: 14px 0;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(18px);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.navbar-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 12px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
}
.brand-icon {
width: 44px;
height: 44px;
border-radius: 16px;
background: linear-gradient(135deg, #0b57d0, #7f39fb);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
box-shadow: 0 12px 20px rgba(11, 87, 208, 0.25);
}
.brand-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.1;
}
.brand-title {
font-size: 20px;
font-weight: 700;
color: var(--text-light);
}
.brand-tagline {
font-size: 12px;
color: var(--text-muted);
}
.navbar-menu {
display: flex;
align-items: center;
gap: 16px;
}
.navbar-nav {
display: flex;
align-items: center;
gap: 10px;
margin-right: 12px;
padding-right: 16px;
border-right: 1px solid var(--border-color);
}
.nav-btn {
display: flex;
align-items: center;
gap: 6px;
background-color: var(--surface-muted);
border: 1px solid transparent;
color: var(--text-light);
padding: 10px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
box-shadow: var(--shadow-inner);
}
.nav-btn:hover {
border-color: rgba(11, 87, 208, 0.2);
color: var(--primary);
background-color: var(--surface);
box-shadow: 0 5px 15px rgba(15, 23, 42, 0.12);
}
.nav-btn.active {
background-color: var(--primary);
border-color: var(--primary);
color: #fff;
box-shadow: 0 10px 18px rgba(11, 87, 208, 0.35);
}
.nav-btn.active:hover {
background-color: var(--primary-dark);
}
.nav-icon {
font-size: 15px;
line-height: 1;
}
.navbar-user {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 14px;
border-radius: 999px;
background: var(--surface-muted);
border: 1px solid transparent;
box-shadow: var(--shadow-inner);
}
.user-info {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: var(--text-light);
}
.user-role {
font-size: 12px;
color: var(--text-muted);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 12px;
background: linear-gradient(135deg, #34a853, #0b57d0);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.btn-logout {
background-color: var(--surface);
border: 1px solid var(--border-color);
color: var(--danger);
padding: 10px 18px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 8px 20px rgba(217, 48, 37, 0.15);
}
.btn-logout:hover {
border-color: var(--danger);
background-color: rgba(217, 48, 37, 0.08);
}
@media (max-width: 768px) {
.navbar-container {
padding: 0 16px;
}
.navbar-brand h2 {
font-size: 20px;
}
.navbar-menu {
gap: 8px;
}
.navbar-nav {
gap: 6px;
margin-right: 6px;
padding-right: 8px;
}
.nav-btn {
padding: 6px 12px;
font-size: 12px;
}
.navbar-user {
display: none;
}
}

View File

@ -0,0 +1,242 @@
.rag-chatbox {
display: flex;
flex-direction: column;
height: 100%;
background: var(--surface);
border: 1px solid rgba(223, 227, 235, 0.9);
border-radius: 24px;
overflow: hidden;
box-shadow: var(--shadow-soft);
}
.chatbox-header {
padding: 18px 22px;
border-bottom: 1px solid rgba(223, 227, 235, 0.7);
background: linear-gradient(135deg, #f8f9fd, #ffffff);
}
.chatbox-header h3 {
font-size: 18px;
font-weight: 700;
color: var(--text-light);
margin: 0 0 4px 0;
}
.chatbox-subtitle {
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.chatbox-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
background: var(--surface-muted);
}
.message {
display: flex;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message.user {
justify-content: flex-end;
}
.message.assistant {
justify-content: flex-start;
}
.message-content {
max-width: 80%;
display: flex;
flex-direction: column;
gap: 8px;
}
.message.user .message-content {
align-items: flex-end;
}
.message.assistant .message-content {
align-items: flex-start;
}
.message-text {
padding: 12px 16px;
border-radius: 18px;
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
box-shadow: var(--shadow-inner);
}
.message.user .message-text {
background: linear-gradient(135deg, #0b57d0, #1a73e8);
color: #fff;
border-bottom-right-radius: 6px;
}
.message.assistant .message-text {
background: var(--surface);
border: 1px solid rgba(223, 227, 235, 0.9);
color: var(--text-light);
border-bottom-left-radius: 6px;
}
.message-sources {
margin-top: 8px;
padding: 12px;
background: rgba(11, 87, 208, 0.07);
border: 1px dashed rgba(11, 87, 208, 0.3);
border-radius: 12px;
width: 100%;
}
.sources-header {
font-size: 12px;
font-weight: 700;
color: var(--primary);
margin-bottom: 8px;
}
.source-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
margin-bottom: 8px;
background: var(--surface);
border-radius: 8px;
}
.source-item:last-child {
margin-bottom: 0;
}
.source-file {
font-size: 12px;
font-weight: 700;
color: var(--text-light);
}
.source-preview {
font-size: 11px;
color: var(--text-muted);
font-style: italic;
}
.source-confidence {
font-size: 10px;
color: var(--text-muted);
}
.message-time {
font-size: 10px;
color: var(--text-muted);
margin-top: 4px;
}
.loading-dots {
display: flex;
gap: 4px;
padding: 12px 16px;
}
.loading-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary);
animation: bounce 1.4s infinite ease-in-out both;
}
.loading-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dots span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.chatbox-input-form {
display: flex;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid rgba(223, 227, 235, 0.7);
background: var(--surface);
}
.chatbox-input {
flex: 1;
padding: 12px 16px;
background-color: var(--surface-muted);
border: 1px solid var(--border-color);
border-radius: 16px;
color: var(--text-light);
font-size: 14px;
transition: all 0.2s ease;
}
.chatbox-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(11, 87, 208, 0.1);
background-color: #fff;
}
.chatbox-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send {
background-color: var(--primary);
color: #fff;
padding: 12px 24px;
border-radius: 16px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
box-shadow: 0 12px 24px rgba(11, 87, 208, 0.25);
}
.btn-send:hover:not(:disabled) {
transform: translateY(-2px);
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@ -0,0 +1,386 @@
.video-call-container {
display: flex;
flex-direction: column;
height: 100%;
background-color: #202124;
position: relative;
border-radius: 0;
overflow: hidden;
}
/* Top toolbar */
.vc-topbar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 10px 18px;
background: transparent;
z-index: 300;
}
.vc-left { display: flex; align-items: center; gap: 12px; color: #fff; }
.vc-title { font-weight: 700; font-size: 15px; }
.vc-timer { color: rgba(255, 255, 255, 0.7); font-size: 12px; }
.vc-actions { display: flex; align-items: center; gap: 8px; }
.vc-top-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255,255,255,0.08);
color: #f5f6fb;
font-weight: 600;
transition: all 0.2s ease;
}
.vc-top-btn:hover { border-color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.15); }
.vc-top-btn.active { border-color: var(--primary); background: rgba(11,87,208,0.15); }
.vc-top-btn.muted { opacity: 0.6; }
.vc-top-btn.leave { background: rgba(217,48,37,0.2); border-color: rgba(217,48,37,0.6); color:#fff; }
.vc-top-btn span { font-size: 12px; }
.remote-videos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 12px;
padding: 12px;
flex: 1;
overflow-y: auto;
padding-bottom: 80px;
}
.remote-video-wrapper,
.local-video-wrapper {
position: relative;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.remote-video,
.local-video {
width: 100%;
height: 100%;
object-fit: cover;
background-color: #1a1a1a;
}
.local-video-wrapper {
position: fixed;
bottom: 80px;
right: 16px;
width: 240px;
height: 180px;
z-index: 200;
border: 2px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
border-radius: 20px;
}
/* Placeholder when no remote */
.vc-placeholder { position: absolute; inset: 60px 0 0 0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; color:#eef2ff; }
.vc-avatar { width: 140px; height: 140px; border-radius:32px; background: linear-gradient(135deg,#0b57d0,#7f39fb); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:800; font-size:48px; box-shadow:0 18px 35px rgba(11,87,208,0.45); }
.vc-invite { color: #e0e0e0; font-weight: 700; font-size: 18px; }
.video-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
color: white;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
}
.video-controls {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 16px;
padding: 16px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0));
z-index: 200;
}
.control-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
color: white;
font-size: 24px;
cursor: pointer;
transition: all 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.control-btn:hover {
background-color: rgba(255, 255, 255, 0.25);
transform: translateY(-4px);
border-color: rgba(255, 255, 255, 0.6);
}
.control-btn.disabled {
background-color: rgba(217, 48, 37, 0.3);
border-color: rgba(217, 48, 37, 0.7);
}
.control-btn.active {
background-color: var(--primary);
border-color: rgba(255, 255, 255, 0.7);
color: #fff;
}
/* People side panel */
.vc-people-panel {
position: absolute;
top: 48px;
right: 0;
width: 320px;
bottom: 0;
background: rgba(32, 33, 36, 0.98);
border-left: 1px solid rgba(255, 255, 255, 0.1);
z-index: 250;
display: flex;
flex-direction: column;
backdrop-filter: blur(10px);
}
.vc-panel-header {
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.1);
color: #ffffff;
font-weight: 700;
font-size: 16px;
}
.vc-people-list {
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.vc-person {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
transition: all 0.2s ease;
}
.vc-person:hover {
background: rgba(255,255,255,0.1);
}
.vc-person-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #4285f4, #1967d2);
color: #ffffff;
display:flex;
align-items:center;
justify-content:center;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.vc-person-name {
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.control-btn.active:hover {
background-color: var(--primary-dark);
border-color: var(--primary-dark);
}
/* Responsive */
@media (max-width: 768px) {
.local-video-wrapper {
width: 160px;
height: 120px;
bottom: 70px;
right: 8px;
}
.remote-videos {
grid-template-columns: 1fr;
}
.video-controls {
padding: 12px;
gap: 12px;
}
.control-btn {
width: 48px;
height: 48px;
font-size: 20px;
}
}
/* Bottom Control Bar */
.vc-bottom-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: rgba(32, 33, 36, 0.9);
backdrop-filter: blur(10px);
z-index: 300;
gap: 16px;
}
.vc-bottom-left,
.vc-bottom-center,
.vc-bottom-right {
display: flex;
align-items: center;
gap: 8px;
}
.vc-bottom-center {
flex: 1;
justify-content: center;
}
.vc-bottom-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.vc-bottom-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.vc-bottom-btn.muted {
background: rgba(234, 67, 53, 0.3);
border: 2px solid rgba(234, 67, 53, 0.6);
}
.vc-bottom-btn.muted:hover {
background: rgba(234, 67, 53, 0.4);
}
.vc-bottom-btn.active {
background: rgba(66, 133, 244, 0.3);
border: 2px solid rgba(66, 133, 244, 0.6);
}
.vc-bottom-btn.end-meeting-btn {
background: rgba(234, 67, 53, 0.3);
border: 2px solid rgba(234, 67, 53, 0.6);
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
}
.vc-bottom-btn.end-meeting-btn:hover {
background: rgba(234, 67, 53, 0.5);
}
.vc-bottom-btn.end-meeting-btn span {
font-size: 13px;
font-weight: 600;
}
.vc-bottom-btn.leave-btn {
background: rgba(234, 67, 53, 0.3);
border: 2px solid rgba(234, 67, 53, 0.6);
}
.vc-bottom-btn.leave-btn:hover {
background: rgba(234, 67, 53, 0.5);
}
.vc-bottom-btn.recording {
background: rgba(234, 67, 53, 0.3);
border: 2px solid rgba(234, 67, 53, 0.7);
animation: recording-pulse 2s ease-in-out infinite;
}
.vc-bottom-btn.recording:hover {
background: rgba(234, 67, 53, 0.4);
}
.vc-bottom-btn.recording svg {
animation: recording-blink 1s ease-in-out infinite;
}
.recording-time {
position: absolute;
top: -28px;
left: 50%;
transform: translateX(-50%);
background: rgba(234, 67, 53, 0.9);
color: #ffffff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
}
@keyframes recording-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(234, 67, 53, 0.7);
}
50% {
box-shadow: 0 0 0 8px rgba(234, 67, 53, 0);
}
}
@keyframes recording-blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.vc-bottom-btn span {
position: absolute;
top: -4px;
right: -4px;
background: var(--primary);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}

43
nginx/conf.d/default.conf Normal file
View File

@ -0,0 +1,43 @@
# ===============================
# HTTP - redirect sang HTTPS
# ===============================
server {
listen 80;
server_name bkmeeting.soict.io;
return 301 https://$host$request_uri;
}
# ===============================
# HTTPS Reverse Proxy
# ===============================
server {
listen 443 ssl;
server_name bkmeeting.soict.io;
ssl_certificate /etc/letsencrypt/live/bkmeeting.soict.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bkmeeting.soict.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# ===========================
# Proxy đến frontend (React)
# ===========================
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ===========================
# Proxy đến backend (Node.js)
# ===========================
location /api/ {
proxy_pass http://backend:5000/;
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;
}
}

61
nginx/nginx.conf Normal file
View File

@ -0,0 +1,61 @@
# ===============================
# HTTP - redirect sang HTTPS
# ===============================
server {
listen 80;
server_name bkmeeting.soict.io;
client_max_body_size 20m;
return 301 https://$host$request_uri;
}
# ===============================
# HTTPS Reverse Proxy
# ===============================
server {
listen 443 ssl;
server_name bkmeeting.soict.io;
client_max_body_size 20m;
ssl_certificate /etc/letsencrypt/live/bkmeeting.soict.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bkmeeting.soict.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# ===========================
# Proxy đến frontend (React)
# ===========================
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 👇 Fix React Router 404 on refresh
proxy_intercept_errors on;
error_page 404 = /index.html;
}
# ===========================
# Proxy đến backend (Node.js)
# ===========================
location /api/ {
proxy_pass http://backend:5000;
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;
}
location /socket.io/ {
proxy_pass http://backend:5000;
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 600s;
proxy_send_timeout 600s;
}
}

81
package.json Normal file
View File

@ -0,0 +1,81 @@
{
"name": "my-v0-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint ."
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "2.2.4",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-menubar": "1.1.4",
"@radix-ui/react-navigation-menu": "1.2.3",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-progress": "1.1.1",
"@radix-ui/react-radio-group": "1.2.2",
"@radix-ui/react-scroll-area": "1.2.2",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-separator": "1.1.1",
"@radix-ui/react-slider": "1.2.2",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"date-fns": "4.1.0",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "16.0.0",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-day-picker": "9.8.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "3.25.76",
"mongoose": "8.19.2",
"express": "5.1.0",
"bcryptjs": "3.0.2",
"jsonwebtoken": "9.0.2",
"dotenv": "17.2.3",
"axios": "1.12.2",
"react-router-dom": "7.9.4",
"@react-oauth/google": "0.12.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
}
}