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