Merge remote-tracking branch 'refs/remotes/origin/main'
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m34s
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m34s
This commit is contained in:
27
Dockerfile
27
Dockerfile
@@ -1,28 +1,31 @@
|
|||||||
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
|
# ── Stage 1: Install dependencies ────────────────────────────────────────────
|
||||||
FROM node:20-bullseye-slim AS builder
|
FROM node:22-trixie-slim AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build tools and sqlite headers for native modules
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# Install build tools for native modules (better-sqlite3, pdfkit)
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
python3 build-essential libsqlite3-dev ca-certificates \
|
python3 build-essential libsqlite3-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
# Install all dependencies (including dev for vite build)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Stage 2: Build frontend ─────────────────────────────────────────────────
|
||||||
|
FROM deps AS builder
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
# Produce production node_modules (compile native modules here for the target arch)
|
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
# ── Stage 2: Production image ───────────────────────────────────────────────
|
# Prune dev dependencies in-place (avoids a second npm ci)
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
FROM node:20-bullseye-slim
|
# ── Stage 3: Production image ───────────────────────────────────────────────
|
||||||
|
FROM node:22-trixie-slim
|
||||||
# Allow forcing build from source (useful when prebuilt binaries are not available)
|
|
||||||
ARG BUILD_FROM_SOURCE=false
|
|
||||||
ENV npm_config_build_from_source=${BUILD_FROM_SOURCE}
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
3348
package-lock.json
generated
3348
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^11.0.0",
|
"better-sqlite3": "^12.6.2",
|
||||||
"concurrently": "^9.0.0",
|
"concurrently": "^9.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^17.3.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"express-rate-limit": "^7.5.1",
|
"express-rate-limit": "^7.5.1",
|
||||||
"flatpickr": "^4.6.13",
|
"flatpickr": "^4.6.13",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"multer": "^2.1.0",
|
"multer": "^2.1.0",
|
||||||
"nodemailer": "^8.0.1",
|
"nodemailer": "^8.0.1",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"rate-limit-redis": "^4.3.1",
|
"rate-limit-redis": "^4.3.1",
|
||||||
@@ -41,10 +43,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0",
|
"postcss": "^8.4.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^6.4.1"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -810,6 +810,11 @@ export async function initDatabase() {
|
|||||||
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
|
await db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER DEFAULT 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Analytics visibility setting ────────────────────────────────────────
|
||||||
|
if (!(await db.columnExists('rooms', 'analytics_visibility'))) {
|
||||||
|
await db.exec("ALTER TABLE rooms ADD COLUMN analytics_visibility TEXT DEFAULT 'owner'");
|
||||||
|
}
|
||||||
|
|
||||||
// ── Default admin (only on very first start) ────────────────────────────
|
// ── Default admin (only on very first start) ────────────────────────────
|
||||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||||
if (!adminAlreadySeeded) {
|
if (!adminAlreadySeeded) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
@@ -72,14 +74,17 @@ router.post('/callback/:uid', async (req, res) => {
|
|||||||
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [req.params.uid]);
|
const room = await db.get('SELECT id, user_id, analytics_visibility FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Room not found' });
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check access: owner, shared, or admin
|
// Check access: owner, shared (if visibility allows), or admin
|
||||||
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
if (room.analytics_visibility !== 'shared') {
|
||||||
|
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
||||||
|
}
|
||||||
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
||||||
@@ -134,4 +139,171 @@ router.delete('/:id', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper: extract flat attendee rows from analytics entry
|
||||||
|
function extractRows(entry) {
|
||||||
|
const data = typeof entry.data === 'string' ? JSON.parse(entry.data) : entry.data;
|
||||||
|
const attendees = data?.data?.attendees || [];
|
||||||
|
const meetingName = data?.data?.metadata?.meeting_name || entry.meeting_name || '';
|
||||||
|
const meetingDuration = data?.data?.duration || 0;
|
||||||
|
const meetingStart = data?.data?.start || '';
|
||||||
|
const meetingFinish = data?.data?.finish || '';
|
||||||
|
|
||||||
|
return attendees.map(a => ({
|
||||||
|
meetingName,
|
||||||
|
meetingStart,
|
||||||
|
meetingFinish,
|
||||||
|
meetingDuration,
|
||||||
|
name: a.name || '',
|
||||||
|
role: a.moderator ? 'Moderator' : 'Viewer',
|
||||||
|
duration: a.duration || 0,
|
||||||
|
talkTime: a.engagement?.talk_time || 0,
|
||||||
|
chats: a.engagement?.chats || 0,
|
||||||
|
talks: a.engagement?.talks || 0,
|
||||||
|
raiseHand: a.engagement?.raisehand || 0,
|
||||||
|
emojis: a.engagement?.emojis || 0,
|
||||||
|
pollVotes: a.engagement?.poll_votes || 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ header: 'Meeting', key: 'meetingName', width: 25 },
|
||||||
|
{ header: 'Start', key: 'meetingStart', width: 20 },
|
||||||
|
{ header: 'End', key: 'meetingFinish', width: 20 },
|
||||||
|
{ header: 'Meeting Duration (s)', key: 'meetingDuration', width: 18 },
|
||||||
|
{ header: 'Name', key: 'name', width: 25 },
|
||||||
|
{ header: 'Role', key: 'role', width: 12 },
|
||||||
|
{ header: 'Duration (s)', key: 'duration', width: 14 },
|
||||||
|
{ header: 'Talk Time (s)', key: 'talkTime', width: 14 },
|
||||||
|
{ header: 'Chats', key: 'chats', width: 8 },
|
||||||
|
{ header: 'Talks', key: 'talks', width: 8 },
|
||||||
|
{ header: 'Raise Hand', key: 'raiseHand', width: 12 },
|
||||||
|
{ header: 'Emojis', key: 'emojis', width: 8 },
|
||||||
|
{ header: 'Poll Votes', key: 'pollVotes', width: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// GET /api/analytics/:id/export/:format - Export a single analytics entry (csv, xlsx, pdf)
|
||||||
|
router.get('/:id/export/:format', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const format = req.params.format;
|
||||||
|
if (!['csv', 'xlsx', 'pdf'].includes(format)) {
|
||||||
|
return res.status(400).json({ error: 'Unsupported format. Use csv, xlsx, or pdf.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const entry = await db.get(
|
||||||
|
`SELECT la.*, r.user_id, r.analytics_visibility
|
||||||
|
FROM learning_analytics_data la
|
||||||
|
JOIN rooms r ON la.room_id = r.id
|
||||||
|
WHERE la.id = ?`,
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return res.status(404).json({ error: 'Analytics entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
if (entry.analytics_visibility !== 'shared') {
|
||||||
|
return res.status(403).json({ error: 'No permission to export this entry' });
|
||||||
|
}
|
||||||
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [entry.room_id, req.user.id]);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(403).json({ error: 'No permission to export this entry' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = extractRows(entry);
|
||||||
|
const safeName = (entry.meeting_name || 'analytics').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const header = COLUMNS.map(c => c.header).join(',');
|
||||||
|
const csvRows = rows.map(r =>
|
||||||
|
COLUMNS.map(c => {
|
||||||
|
const val = r[c.key];
|
||||||
|
if (typeof val === 'string' && (val.includes(',') || val.includes('"') || val.includes('\n'))) {
|
||||||
|
return '"' + val.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}).join(',')
|
||||||
|
);
|
||||||
|
const csv = [header, ...csvRows].join('\n');
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.csv"`);
|
||||||
|
return res.send('\uFEFF' + csv); // BOM for Excel UTF-8
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'xlsx') {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('Analytics');
|
||||||
|
sheet.columns = COLUMNS;
|
||||||
|
rows.forEach(r => sheet.addRow(r));
|
||||||
|
// Style header row
|
||||||
|
sheet.getRow(1).font = { bold: true };
|
||||||
|
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.xlsx"`);
|
||||||
|
await workbook.xlsx.write(res);
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'pdf') {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 30 });
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`);
|
||||||
|
doc.pipe(res);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
doc.fontSize(16).text(entry.meeting_name || 'Learning Analytics', { align: 'center' });
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Table header columns for PDF (subset for readability)
|
||||||
|
const pdfCols = [
|
||||||
|
{ header: 'Name', key: 'name', width: 120 },
|
||||||
|
{ header: 'Role', key: 'role', width: 65 },
|
||||||
|
{ header: 'Duration (s)', key: 'duration', width: 75 },
|
||||||
|
{ header: 'Talk Time (s)', key: 'talkTime', width: 75 },
|
||||||
|
{ header: 'Chats', key: 'chats', width: 50 },
|
||||||
|
{ header: 'Talks', key: 'talks', width: 50 },
|
||||||
|
{ header: 'Raise Hand', key: 'raiseHand', width: 65 },
|
||||||
|
{ header: 'Emojis', key: 'emojis', width: 50 },
|
||||||
|
{ header: 'Poll Votes', key: 'pollVotes', width: 60 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const startX = doc.x;
|
||||||
|
let y = doc.y;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
doc.fontSize(8).font('Helvetica-Bold');
|
||||||
|
pdfCols.forEach((col, i) => {
|
||||||
|
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
|
||||||
|
doc.text(col.header, x, y, { width: col.width, align: 'left' });
|
||||||
|
});
|
||||||
|
y += 14;
|
||||||
|
doc.moveTo(startX, y).lineTo(startX + pdfCols.reduce((s, c) => s + c.width, 0), y).stroke();
|
||||||
|
y += 4;
|
||||||
|
|
||||||
|
// Rows
|
||||||
|
doc.font('Helvetica').fontSize(8);
|
||||||
|
rows.forEach(r => {
|
||||||
|
if (y > doc.page.height - 50) {
|
||||||
|
doc.addPage();
|
||||||
|
y = 30;
|
||||||
|
}
|
||||||
|
pdfCols.forEach((col, i) => {
|
||||||
|
const x = startX + pdfCols.slice(0, i).reduce((s, c) => s + c.width, 0);
|
||||||
|
doc.text(String(r[col.key]), x, y, { width: col.width, align: 'left' });
|
||||||
|
});
|
||||||
|
y += 14;
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`Export analytics error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Error exporting analytics data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function wellKnownHandler(req, res) {
|
|||||||
federation_api: '/api/federation',
|
federation_api: '/api/federation',
|
||||||
public_key: getPublicKey(),
|
public_key: getPublicKey(),
|
||||||
software: 'Redlight',
|
software: 'Redlight',
|
||||||
version: '2.0.0',
|
version: '2.1.0',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
guest_access,
|
guest_access,
|
||||||
moderator_code,
|
moderator_code,
|
||||||
learning_analytics,
|
learning_analytics,
|
||||||
|
analytics_visibility,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// M12: field length limits (same as create)
|
// M12: field length limits (same as create)
|
||||||
@@ -285,6 +286,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
guest_access = COALESCE(?, guest_access),
|
guest_access = COALESCE(?, guest_access),
|
||||||
moderator_code = ?,
|
moderator_code = ?,
|
||||||
learning_analytics = COALESCE(?, learning_analytics),
|
learning_analytics = COALESCE(?, learning_analytics),
|
||||||
|
analytics_visibility = COALESCE(?, analytics_visibility),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE uid = ?
|
WHERE uid = ?
|
||||||
`, [
|
`, [
|
||||||
@@ -300,6 +302,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
||||||
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
||||||
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
learning_analytics !== undefined ? (learning_analytics ? 1 : 0) : null,
|
||||||
|
analytics_visibility && ['owner', 'shared'].includes(analytics_visibility) ? analytics_visibility : null,
|
||||||
req.params.uid,
|
req.params.uid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2 } from 'lucide-react';
|
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp, Hand, BarChart2, Download } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function AnalyticsList({ analytics, onRefresh }) {
|
export default function AnalyticsList({ analytics, onRefresh, isOwner = true }) {
|
||||||
const [loading, setLoading] = useState({});
|
const [loading, setLoading] = useState({});
|
||||||
const [expanded, setExpanded] = useState({});
|
const [expanded, setExpanded] = useState({});
|
||||||
|
const [exportMenu, setExportMenu] = useState({});
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -49,6 +50,34 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
|||||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleExportMenu = (id) => {
|
||||||
|
setExportMenu(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (id, format) => {
|
||||||
|
setExportMenu(prev => ({ ...prev, [id]: false }));
|
||||||
|
setLoading(prev => ({ ...prev, [id]: 'exporting' }));
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/analytics/${id}/export/${format}`, { responseType: 'blob' });
|
||||||
|
const disposition = response.headers['content-disposition'];
|
||||||
|
const match = disposition?.match(/filename="?([^"]+)"?/);
|
||||||
|
const filename = match?.[1] || `analytics.${format}`;
|
||||||
|
const url = window.URL.createObjectURL(response.data);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
toast.success(t('analytics.exportSuccess'));
|
||||||
|
} catch {
|
||||||
|
toast.error(t('analytics.exportFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(prev => ({ ...prev, [id]: null }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Extract user summary from BBB learning analytics callback data
|
// Extract user summary from BBB learning analytics callback data
|
||||||
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
|
// Payload: { meeting_id, data: { duration, start, finish, attendees: [{ name, moderator, duration, engagement: { chats, talks, raisehand, emojis, poll_votes, talk_time } }] } }
|
||||||
const getUserSummary = (data) => {
|
const getUserSummary = (data) => {
|
||||||
@@ -124,6 +153,38 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExportMenu(entry.id)}
|
||||||
|
disabled={loading[entry.id] === 'exporting'}
|
||||||
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
||||||
|
title={t('analytics.export')}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</button>
|
||||||
|
{exportMenu[entry.id] && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 bg-th-bg border border-th-border rounded-lg shadow-lg z-10 min-w-[120px] py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(entry.id, 'csv')}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(entry.id, 'xlsx')}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||||
|
>
|
||||||
|
Excel (.xlsx)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport(entry.id, 'pdf')}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||||
|
>
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleExpand(entry.id)}
|
onClick={() => toggleExpand(entry.id)}
|
||||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
||||||
@@ -131,6 +192,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
|||||||
>
|
>
|
||||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</button>
|
</button>
|
||||||
|
{isOwner && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(entry.id)}
|
onClick={() => handleDelete(entry.id)}
|
||||||
disabled={loading[entry.id] === 'deleting'}
|
disabled={loading[entry.id] === 'deleting'}
|
||||||
@@ -139,6 +201,7 @@ export default function AnalyticsList({ analytics, onRefresh }) {
|
|||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -266,7 +266,11 @@
|
|||||||
"defaultWelcome": "Willkommen zum Meeting!",
|
"defaultWelcome": "Willkommen zum Meeting!",
|
||||||
"analytics": "Lernanalyse",
|
"analytics": "Lernanalyse",
|
||||||
"enableAnalytics": "Lernanalyse aktivieren",
|
"enableAnalytics": "Lernanalyse aktivieren",
|
||||||
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting."
|
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting.",
|
||||||
|
"analyticsVisibility": "Wer kann die Analyse sehen?",
|
||||||
|
"analyticsOwnerOnly": "Nur Raumbesitzer",
|
||||||
|
"analyticsSharedUsers": "Alle geteilten Benutzer",
|
||||||
|
"analyticsVisibilityHint": "Legt fest, wer die Analysedaten dieses Raums einsehen und exportieren kann."
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Aufnahmen",
|
"title": "Aufnahmen",
|
||||||
@@ -303,7 +307,10 @@
|
|||||||
"duration": "Dauer",
|
"duration": "Dauer",
|
||||||
"meetingDuration": "Meeting-Dauer",
|
"meetingDuration": "Meeting-Dauer",
|
||||||
"raiseHand": "Handheben",
|
"raiseHand": "Handheben",
|
||||||
"reactions": "Reaktionen"
|
"reactions": "Reaktionen",
|
||||||
|
"export": "Herunterladen",
|
||||||
|
"exportSuccess": "Download gestartet",
|
||||||
|
"exportFailed": "Fehler beim Herunterladen"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
|
|||||||
@@ -266,7 +266,11 @@
|
|||||||
"defaultWelcome": "Welcome to the meeting!",
|
"defaultWelcome": "Welcome to the meeting!",
|
||||||
"analytics": "Learning Analytics",
|
"analytics": "Learning Analytics",
|
||||||
"enableAnalytics": "Enable learning analytics",
|
"enableAnalytics": "Enable learning analytics",
|
||||||
"enableAnalyticsHint": "Collects participant engagement data after each meeting."
|
"enableAnalyticsHint": "Collects participant engagement data after each meeting.",
|
||||||
|
"analyticsVisibility": "Who can see analytics?",
|
||||||
|
"analyticsOwnerOnly": "Room owner only",
|
||||||
|
"analyticsSharedUsers": "All shared users",
|
||||||
|
"analyticsVisibilityHint": "Controls who can view and export analytics data for this room."
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Recordings",
|
"title": "Recordings",
|
||||||
@@ -303,7 +307,10 @@
|
|||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"meetingDuration": "Meeting duration",
|
"meetingDuration": "Meeting duration",
|
||||||
"raiseHand": "Raise hand",
|
"raiseHand": "Raise hand",
|
||||||
"reactions": "Reactions"
|
"reactions": "Reactions",
|
||||||
|
"export": "Download",
|
||||||
|
"exportSuccess": "Download started",
|
||||||
|
"exportFailed": "Error downloading data"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export default function RoomDetail() {
|
|||||||
guest_access: !!editRoom.guest_access,
|
guest_access: !!editRoom.guest_access,
|
||||||
moderator_code: editRoom.moderator_code,
|
moderator_code: editRoom.moderator_code,
|
||||||
learning_analytics: !!editRoom.learning_analytics,
|
learning_analytics: !!editRoom.learning_analytics,
|
||||||
|
analytics_visibility: editRoom.analytics_visibility || 'owner',
|
||||||
});
|
});
|
||||||
setRoom(res.data.room);
|
setRoom(res.data.room);
|
||||||
setEditRoom(res.data.room);
|
setEditRoom(res.data.room);
|
||||||
@@ -344,7 +345,7 @@ export default function RoomDetail() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: t('room.overview'), icon: Play },
|
{ id: 'overview', label: t('room.overview'), icon: Play },
|
||||||
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
||||||
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length },
|
{ id: 'analytics', label: t('room.analytics'), icon: BarChart3, count: analytics.length, hidden: !room.learning_analytics || (isShared && room.analytics_visibility !== 'shared') },
|
||||||
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -451,7 +452,7 @@ export default function RoomDetail() {
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
||||||
{tabs.map(tab => (
|
{tabs.filter(tab => !tab.hidden).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
@@ -543,7 +544,7 @@ export default function RoomDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'analytics' && (
|
{activeTab === 'analytics' && (
|
||||||
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
|
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && isOwner && editRoom && (
|
{activeTab === 'settings' && isOwner && editRoom && (
|
||||||
@@ -648,6 +649,20 @@ export default function RoomDetail() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
{!!editRoom.learning_analytics && (
|
||||||
|
<div className="ml-7">
|
||||||
|
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
|
||||||
|
<select
|
||||||
|
value={editRoom.analytics_visibility || 'owner'}
|
||||||
|
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
|
||||||
|
className="input-field text-sm py-1.5 max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="owner">{t('room.analyticsOwnerOnly')}</option>
|
||||||
|
<option value="shared">{t('room.analyticsSharedUsers')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('room.analyticsVisibilityHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guest access section */}
|
{/* Guest access section */}
|
||||||
|
|||||||
Reference in New Issue
Block a user