first commit
This commit is contained in:
commit
1eaf20042f
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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/
|
||||
77
docker-compose.yml
Normal file
77
docker-compose.yml
Normal 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
|
||||
7
meeting-backend/.dockerignore
Normal file
7
meeting-backend/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
.env
|
||||
test
|
||||
uploads
|
||||
.vscode
|
||||
.git
|
||||
31
meeting-backend/.gitignore
vendored
Normal file
31
meeting-backend/.gitignore
vendored
Normal 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
|
||||
|
||||
|
||||
20
meeting-backend/Dockerfile
Normal file
20
meeting-backend/Dockerfile
Normal 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"]
|
||||
37
meeting-backend/package.json
Normal file
37
meeting-backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
meeting-backend/scripts/seedAdmin.js
Normal file
46
meeting-backend/scripts/seedAdmin.js
Normal 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()
|
||||
13
meeting-backend/src/config/db.js
Normal file
13
meeting-backend/src/config/db.js
Normal 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;
|
||||
30
meeting-backend/src/middleware/authMiddleware.js
Normal file
30
meeting-backend/src/middleware/authMiddleware.js
Normal 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" })
|
||||
}
|
||||
}
|
||||
55
meeting-backend/src/middleware/uploadAudioMiddleware.js
Normal file
55
meeting-backend/src/middleware/uploadAudioMiddleware.js
Normal 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")
|
||||
|
||||
56
meeting-backend/src/middleware/uploadMiddleware.js
Normal file
56
meeting-backend/src/middleware/uploadMiddleware.js
Normal 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")
|
||||
35
meeting-backend/src/models/Document.js
Normal file
35
meeting-backend/src/models/Document.js
Normal 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)
|
||||
16
meeting-backend/src/models/Meeting.js
Normal file
16
meeting-backend/src/models/Meeting.js
Normal 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)
|
||||
41
meeting-backend/src/models/MeetingMinutes.js
Normal file
41
meeting-backend/src/models/MeetingMinutes.js
Normal 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)
|
||||
|
||||
15
meeting-backend/src/models/User.js
Normal file
15
meeting-backend/src/models/User.js
Normal 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)
|
||||
128
meeting-backend/src/routes/auth.js
Normal file
128
meeting-backend/src/routes/auth.js
Normal 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
|
||||
345
meeting-backend/src/routes/document.js
Normal file
345
meeting-backend/src/routes/document.js
Normal 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
|
||||
167
meeting-backend/src/routes/meeting.js
Normal file
167
meeting-backend/src/routes/meeting.js
Normal 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
|
||||
240
meeting-backend/src/routes/minutes.js
Normal file
240
meeting-backend/src/routes/minutes.js
Normal 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
|
||||
|
||||
64
meeting-backend/src/routes/user.js
Normal file
64
meeting-backend/src/routes/user.js
Normal 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
|
||||
44
meeting-backend/src/server.js
Normal file
44
meeting-backend/src/server.js
Normal 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}`));
|
||||
242
meeting-backend/src/services/documentProcessor.js
Normal file
242
meeting-backend/src/services/documentProcessor.js
Normal 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
|
||||
}
|
||||
}
|
||||
206
meeting-backend/src/services/ragService.js
Normal file
206
meeting-backend/src/services/ragService.js
Normal 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 rõ "Không đủ thông tin trong tài liệu"
|
||||
3. Trích dẫn nguồn (tên file) khi có thể
|
||||
4. Trả lời bằng tiếng Việt, ngắn gọn và 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
|
||||
}
|
||||
}
|
||||
470
meeting-backend/src/socket/socketHandler.js
Normal file
470
meeting-backend/src/socket/socketHandler.js
Normal 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 }
|
||||
0
meeting-backend/test/data/05-versions-space.pdf
Normal file
0
meeting-backend/test/data/05-versions-space.pdf
Normal file
1237
meeting-backend/uploads/1762218354571-276475551.pdf
Normal file
1237
meeting-backend/uploads/1762218354571-276475551.pdf
Normal file
File diff suppressed because it is too large
Load Diff
6
meeting-frontend/.dockerignore
Normal file
6
meeting-frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
build
|
||||
npm-debug.log
|
||||
.env
|
||||
.git
|
||||
.vscode
|
||||
23
meeting-frontend/.gitignore
vendored
Normal file
23
meeting-frontend/.gitignore
vendored
Normal 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*
|
||||
26
meeting-frontend/Dockerfile
Normal file
26
meeting-frontend/Dockerfile
Normal 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
17598
meeting-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
meeting-frontend/package.json
Normal file
27
meeting-frontend/package.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
14
meeting-frontend/public/index.html
Normal file
14
meeting-frontend/public/index.html
Normal 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>
|
||||
4
meeting-frontend/src/App.css
Normal file
4
meeting-frontend/src/App.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.app {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #e8f0fe, #f4f6fb 55%, #fdfdfd);
|
||||
}
|
||||
54
meeting-frontend/src/App.js
Normal file
54
meeting-frontend/src/App.js
Normal 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
|
||||
60
meeting-frontend/src/api/auth.js
Normal file
60
meeting-frontend/src/api/auth.js
Normal 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}`),
|
||||
}
|
||||
34
meeting-frontend/src/api/client.js
Normal file
34
meeting-frontend/src/api/client.js
Normal 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
|
||||
123
meeting-frontend/src/components/DocumentUpload.js
Normal file
123
meeting-frontend/src/components/DocumentUpload.js
Normal 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>
|
||||
)
|
||||
}
|
||||
86
meeting-frontend/src/components/Navbar.js
Normal file
86
meeting-frontend/src/components/Navbar.js
Normal 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>
|
||||
)
|
||||
}
|
||||
21
meeting-frontend/src/components/ProtectedRoute.js
Normal file
21
meeting-frontend/src/components/ProtectedRoute.js
Normal 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
|
||||
}
|
||||
135
meeting-frontend/src/components/RAGChatbox.js
Normal file
135
meeting-frontend/src/components/RAGChatbox.js
Normal 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ợ lý</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>
|
||||
)
|
||||
}
|
||||
1934
meeting-frontend/src/components/VideoCall.js
Normal file
1934
meeting-frontend/src/components/VideoCall.js
Normal file
File diff suppressed because it is too large
Load Diff
48
meeting-frontend/src/context/AuthContext.js
Normal file
48
meeting-frontend/src/context/AuthContext.js
Normal 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>
|
||||
}
|
||||
12
meeting-frontend/src/hooks/useAuth.js
Normal file
12
meeting-frontend/src/hooks/useAuth.js
Normal 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
|
||||
}
|
||||
74
meeting-frontend/src/index.css
Normal file
74
meeting-frontend/src/index.css
Normal 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);
|
||||
}
|
||||
11
meeting-frontend/src/index.js
Normal file
11
meeting-frontend/src/index.js
Normal 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>,
|
||||
)
|
||||
159
meeting-frontend/src/pages/AdminDashboardPage.js
Normal file
159
meeting-frontend/src/pages/AdminDashboardPage.js
Normal 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 lý người dùng và duyệt đăng ký</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 có 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 ký</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>
|
||||
)
|
||||
}
|
||||
260
meeting-frontend/src/pages/DashboardPage.js
Normal file
260
meeting-frontend/src/pages/DashboardPage.js
Normal 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 lý 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>Mô 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 có 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>
|
||||
)
|
||||
}
|
||||
151
meeting-frontend/src/pages/LoginPage.js
Normal file
151
meeting-frontend/src/pages/LoginPage.js
Normal 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 ký ngay</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
998
meeting-frontend/src/pages/MeetingRoomPage.js
Normal file
998
meeting-frontend/src/pages/MeetingRoomPage.js
Normal 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 tư
|
||||
{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 có 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 gõ...</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 có 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 có 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 cá 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ử lý 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 có 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>
|
||||
)
|
||||
}
|
||||
45
meeting-frontend/src/pages/Meetings.js
Normal file
45
meeting-frontend/src/pages/Meetings.js
Normal 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;
|
||||
169
meeting-frontend/src/pages/RegisterPage.js
Normal file
169
meeting-frontend/src/pages/RegisterPage.js
Normal 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 ký ngay!</h1>
|
||||
<p className="auth-subtitle">Nhập các thông tin dưới đây để đăng ký 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ọ và 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">
|
||||
Đã có tài khoản? <Link to="/login">Đăng nhập ngay</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
meeting-frontend/src/styles/AdminDashboard.css
Normal file
248
meeting-frontend/src/styles/AdminDashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
203
meeting-frontend/src/styles/AuthPages.css
Normal file
203
meeting-frontend/src/styles/AuthPages.css
Normal 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;
|
||||
}
|
||||
}
|
||||
371
meeting-frontend/src/styles/Dashboard.css
Normal file
371
meeting-frontend/src/styles/Dashboard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
134
meeting-frontend/src/styles/DocumentUpload.css
Normal file
134
meeting-frontend/src/styles/DocumentUpload.css
Normal 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);
|
||||
}
|
||||
126
meeting-frontend/src/styles/Documents.css
Normal file
126
meeting-frontend/src/styles/Documents.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1600
meeting-frontend/src/styles/MeetingRoom.css
Normal file
1600
meeting-frontend/src/styles/MeetingRoom.css
Normal file
File diff suppressed because it is too large
Load Diff
204
meeting-frontend/src/styles/Navbar.css
Normal file
204
meeting-frontend/src/styles/Navbar.css
Normal 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;
|
||||
}
|
||||
}
|
||||
242
meeting-frontend/src/styles/RAGChatbox.css
Normal file
242
meeting-frontend/src/styles/RAGChatbox.css
Normal 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;
|
||||
}
|
||||
386
meeting-frontend/src/styles/VideoCall.css
Normal file
386
meeting-frontend/src/styles/VideoCall.css
Normal 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
43
nginx/conf.d/default.conf
Normal 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
61
nginx/nginx.conf
Normal 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
81
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user