feat(analytics): implement learning analytics feature with data collection and display
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m33s
This commit is contained in:
@@ -73,7 +73,7 @@ function getRoomPasswords(uid) {
|
|||||||
return { moderatorPW: modPw, attendeePW: attPw };
|
return { moderatorPW: modPw, attendeePW: attPw };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null) {
|
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) {
|
||||||
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
||||||
|
|
||||||
// Build welcome message with guest invite link
|
// Build welcome message with guest invite link
|
||||||
@@ -111,6 +111,9 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
|
|||||||
if (room.access_code) {
|
if (room.access_code) {
|
||||||
params.lockSettingsLockOnJoin = 'true';
|
params.lockSettingsLockOnJoin = 'true';
|
||||||
}
|
}
|
||||||
|
if (analyticsCallbackURL) {
|
||||||
|
params['meta_analytics-callback-url'] = analyticsCallbackURL;
|
||||||
|
}
|
||||||
|
|
||||||
// Build optional presentation XML body - escape URL to prevent XML injection
|
// Build optional presentation XML body - escape URL to prevent XML injection
|
||||||
let xmlBody = null;
|
let xmlBody = null;
|
||||||
|
|||||||
@@ -768,6 +768,40 @@ export async function initDatabase() {
|
|||||||
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL');
|
await db.exec('ALTER TABLE users ADD COLUMN oauth_provider_id TEXT DEFAULT NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Learning Analytics table ─────────────────────────────────────────────
|
||||||
|
if (!(await db.columnExists('rooms', 'learning_analytics'))) {
|
||||||
|
await db.exec('ALTER TABLE rooms ADD COLUMN learning_analytics INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_analytics_data (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
meeting_name TEXT,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS learning_analytics_data (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
meeting_id TEXT NOT NULL,
|
||||||
|
meeting_name TEXT,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_room_id ON learning_analytics_data(room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_meeting_id ON learning_analytics_data(meeting_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import calendarRoutes from './routes/calendar.js';
|
|||||||
import caldavRoutes from './routes/caldav.js';
|
import caldavRoutes from './routes/caldav.js';
|
||||||
import notificationRoutes from './routes/notifications.js';
|
import notificationRoutes from './routes/notifications.js';
|
||||||
import oauthRoutes from './routes/oauth.js';
|
import oauthRoutes from './routes/oauth.js';
|
||||||
|
import analyticsRoutes from './routes/analytics.js';
|
||||||
import { startFederationSync } from './jobs/federationSync.js';
|
import { startFederationSync } from './jobs/federationSync.js';
|
||||||
import { startCalendarReminders } from './jobs/calendarReminders.js';
|
import { startCalendarReminders } from './jobs/calendarReminders.js';
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ async function start() {
|
|||||||
app.use('/api/calendar', calendarRoutes);
|
app.use('/api/calendar', calendarRoutes);
|
||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
app.use('/api/oauth', oauthRoutes);
|
app.use('/api/oauth', oauthRoutes);
|
||||||
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
// CalDAV — mounted outside /api so calendar clients use a clean path
|
// CalDAV — mounted outside /api so calendar clients use a clean path
|
||||||
app.use('/caldav', caldavRoutes);
|
app.use('/caldav', caldavRoutes);
|
||||||
// Mount calendar federation receive also under /api/federation for remote instances
|
// Mount calendar federation receive also under /api/federation for remote instances
|
||||||
|
|||||||
125
server/routes/analytics.js
Normal file
125
server/routes/analytics.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getDb } from '../config/database.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
import { log } from '../config/logger.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// POST /api/analytics/callback/:uid - BBB Learning Analytics callback (no auth, called by BBB)
|
||||||
|
router.post('/callback/:uid', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.learning_analytics) {
|
||||||
|
return res.status(403).json({ error: 'Learning analytics not enabled for this room' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = req.body;
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return res.status(400).json({ error: 'Invalid analytics data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract meeting info from BBB analytics payload
|
||||||
|
const meetingId = data.intId || data.extId || room.uid;
|
||||||
|
const meetingName = data.name || room.uid;
|
||||||
|
|
||||||
|
// Upsert: update if same meeting already exists (BBB sends updates during the meeting)
|
||||||
|
const existing = await db.get(
|
||||||
|
'SELECT id FROM learning_analytics_data WHERE room_id = ? AND meeting_id = ?',
|
||||||
|
[room.id, meetingId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonData = JSON.stringify(data);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db.run(
|
||||||
|
'UPDATE learning_analytics_data SET data = ?, meeting_name = ?, created_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[jsonData, meetingName, existing.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.run(
|
||||||
|
'INSERT INTO learning_analytics_data (room_id, meeting_id, meeting_name, data) VALUES (?, ?, ?, ?)',
|
||||||
|
[room.id, meetingId, meetingName, jsonData]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.server.info(`Analytics callback received for room ${room.uid} (meeting: ${meetingId})`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`Analytics callback error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Error processing analytics data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/analytics/room/:uid - Get analytics for a room (authenticated)
|
||||||
|
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const room = await db.get('SELECT id, user_id FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return res.status(404).json({ error: 'Room not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access: owner, shared, or admin
|
||||||
|
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||||
|
const share = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
|
||||||
|
if (!share) {
|
||||||
|
return res.status(403).json({ error: 'No permission to view analytics for this room' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.all(
|
||||||
|
'SELECT id, meeting_id, meeting_name, data, created_at FROM learning_analytics_data WHERE room_id = ? ORDER BY created_at DESC',
|
||||||
|
[room.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const analytics = rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
meetingId: row.meeting_id,
|
||||||
|
meetingName: row.meeting_name,
|
||||||
|
data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ analytics });
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`Get analytics error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Error fetching analytics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/analytics/:id - Delete analytics entry (authenticated, owner only)
|
||||||
|
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const entry = await db.get(
|
||||||
|
`SELECT la.id, la.room_id, r.user_id
|
||||||
|
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') {
|
||||||
|
return res.status(403).json({ error: 'No permission to delete this entry' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run('DELETE FROM learning_analytics_data WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.server.error(`Delete analytics error: ${err.message}`);
|
||||||
|
res.status(500).json({ error: 'Error deleting analytics entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -282,6 +282,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
record_meeting = COALESCE(?, record_meeting),
|
record_meeting = COALESCE(?, record_meeting),
|
||||||
guest_access = COALESCE(?, guest_access),
|
guest_access = COALESCE(?, guest_access),
|
||||||
moderator_code = ?,
|
moderator_code = ?,
|
||||||
|
learning_analytics = COALESCE(?, learning_analytics),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE uid = ?
|
WHERE uid = ?
|
||||||
`, [
|
`, [
|
||||||
@@ -296,6 +297,7 @@ router.put('/:uid', authenticateToken, async (req, res) => {
|
|||||||
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
|
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
|
||||||
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,
|
||||||
req.params.uid,
|
req.params.uid,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -480,7 +482,10 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
|
|||||||
const presentationUrl = room.presentation_file
|
const presentationUrl = room.presentation_file
|
||||||
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
|
? `${baseUrl}/uploads/presentations/${room.presentation_file}`
|
||||||
: null;
|
: null;
|
||||||
await createMeeting(room, baseUrl, loginURL, presentationUrl);
|
const analyticsCallbackURL = room.learning_analytics
|
||||||
|
? `${baseUrl}/api/analytics/callback/${room.uid}`
|
||||||
|
: null;
|
||||||
|
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL);
|
||||||
const avatarURL = getUserAvatarURL(req, req.user);
|
const avatarURL = getUserAvatarURL(req, req.user);
|
||||||
const displayName = req.user.display_name || req.user.name;
|
const displayName = req.user.display_name || req.user.name;
|
||||||
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
|
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
|
||||||
@@ -625,7 +630,10 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
|
|||||||
if (!running && room.anyone_can_start) {
|
if (!running && room.anyone_can_start) {
|
||||||
const baseUrl = getBaseUrl(req);
|
const baseUrl = getBaseUrl(req);
|
||||||
const loginURL = `${baseUrl}/join/${room.uid}`;
|
const loginURL = `${baseUrl}/join/${room.uid}`;
|
||||||
await createMeeting(room, baseUrl, loginURL);
|
const analyticsCallbackURL = room.learning_analytics
|
||||||
|
? `${baseUrl}/api/analytics/callback/${room.uid}`
|
||||||
|
: null;
|
||||||
|
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check moderator code
|
// Check moderator code
|
||||||
|
|||||||
178
src/components/AnalyticsList.jsx
Normal file
178
src/components/AnalyticsList.jsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { BarChart3, Trash2, Clock, Users, MessageSquare, Video, Mic, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function AnalyticsList({ analytics, onRefresh }) {
|
||||||
|
const [loading, setLoading] = useState({});
|
||||||
|
const [expanded, setExpanded] = useState({});
|
||||||
|
const { t, language } = useLanguage();
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDurationMs = (ms) => {
|
||||||
|
if (!ms || ms <= 0) return '0m';
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm(t('analytics.deleteConfirm'))) return;
|
||||||
|
setLoading(prev => ({ ...prev, [id]: 'deleting' }));
|
||||||
|
try {
|
||||||
|
await api.delete(`/analytics/${id}`);
|
||||||
|
toast.success(t('analytics.deleted'));
|
||||||
|
onRefresh?.();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('analytics.deleteFailed'));
|
||||||
|
} finally {
|
||||||
|
setLoading(prev => ({ ...prev, [id]: null }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (id) => {
|
||||||
|
setExpanded(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract user summary from BBB learning analytics data
|
||||||
|
const getUserSummary = (data) => {
|
||||||
|
if (!data?.users) return [];
|
||||||
|
const usersObj = typeof data.users === 'object' ? data.users : {};
|
||||||
|
return Object.values(usersObj).map(u => ({
|
||||||
|
name: u.name || '—',
|
||||||
|
isModerator: u.isModerator || false,
|
||||||
|
totalTime: u.registeredOn && u.leftOn ? u.leftOn - u.registeredOn : 0,
|
||||||
|
talkTime: u.talk?.totalTime || 0,
|
||||||
|
webcamTime: Array.isArray(u.webcams) ? u.webcams.reduce((acc, w) => acc + ((w.stoppedSharingAt || Date.now()) - (w.startedSharingAt || 0)), 0) : 0,
|
||||||
|
messages: u.totalOfMessages || 0,
|
||||||
|
emojis: Array.isArray(u.emojis) ? u.emojis.length : 0,
|
||||||
|
raiseHand: Array.isArray(u.raiseHand) ? u.raiseHand.length : 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!analytics || analytics.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<BarChart3 size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||||
|
<p className="text-th-text-s text-sm">{t('analytics.noData')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{analytics.map(entry => {
|
||||||
|
const users = getUserSummary(entry.data);
|
||||||
|
const isExpanded = expanded[entry.id];
|
||||||
|
const totalParticipants = users.length;
|
||||||
|
const totalMessages = users.reduce((sum, u) => sum + u.messages, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={entry.id} className="card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h4 className="text-sm font-medium text-th-text truncate">
|
||||||
|
{entry.meetingName || entry.meetingId}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs text-th-text-s">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={12} />
|
||||||
|
{formatDate(entry.createdAt)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Users size={12} />
|
||||||
|
{totalParticipants} {t('analytics.participants')}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MessageSquare size={12} />
|
||||||
|
{totalMessages} {t('analytics.messages')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleExpand(entry.id)}
|
||||||
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-text transition-colors"
|
||||||
|
title={isExpanded ? t('analytics.collapse') : t('analytics.expand')}
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(entry.id)}
|
||||||
|
disabled={loading[entry.id] === 'deleting'}
|
||||||
|
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && users.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-th-border pt-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-th-text-s border-b border-th-border">
|
||||||
|
<th className="pb-2 pr-4 font-medium">{t('analytics.userName')}</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">{t('analytics.role')}</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">
|
||||||
|
<span className="flex items-center gap-1"><Mic size={11} />{t('analytics.talkTime')}</span>
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">
|
||||||
|
<span className="flex items-center gap-1"><Video size={11} />{t('analytics.webcamTime')}</span>
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">
|
||||||
|
<span className="flex items-center gap-1"><MessageSquare size={11} />{t('analytics.messages')}</span>
|
||||||
|
</th>
|
||||||
|
<th className="pb-2 font-medium">{t('analytics.reactions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u, i) => (
|
||||||
|
<tr key={i} className="border-b border-th-border/50 last:border-0">
|
||||||
|
<td className="py-2 pr-4 text-th-text font-medium">{u.name}</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||||
|
u.isModerator
|
||||||
|
? 'bg-th-accent/15 text-th-accent'
|
||||||
|
: 'bg-th-bg-s text-th-text-s'
|
||||||
|
}`}>
|
||||||
|
{u.isModerator ? t('analytics.moderator') : t('analytics.viewer')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-th-text-s">{formatDurationMs(u.talkTime)}</td>
|
||||||
|
<td className="py-2 pr-4 text-th-text-s">{formatDurationMs(u.webcamTime)}</td>
|
||||||
|
<td className="py-2 pr-4 text-th-text-s">{u.messages}</td>
|
||||||
|
<td className="py-2 text-th-text-s">{u.emojis + u.raiseHand}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -255,7 +255,10 @@
|
|||||||
"shareRemoved": "Freigabe entfernt",
|
"shareRemoved": "Freigabe entfernt",
|
||||||
"shareFailed": "Freigabe fehlgeschlagen",
|
"shareFailed": "Freigabe fehlgeschlagen",
|
||||||
"shareRemove": "Freigabe entfernen",
|
"shareRemove": "Freigabe entfernen",
|
||||||
"defaultWelcome": "Willkommen zum Meeting!"
|
"defaultWelcome": "Willkommen zum Meeting!",
|
||||||
|
"analytics": "Lernanalyse",
|
||||||
|
"enableAnalytics": "Lernanalyse aktivieren",
|
||||||
|
"enableAnalyticsHint": "Sammelt Engagement-Daten der Teilnehmer nach jedem Meeting."
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Aufnahmen",
|
"title": "Aufnahmen",
|
||||||
@@ -273,6 +276,24 @@
|
|||||||
"publish": "Veröffentlichen",
|
"publish": "Veröffentlichen",
|
||||||
"loadFailed": "Aufnahmen konnten nicht geladen werden"
|
"loadFailed": "Aufnahmen konnten nicht geladen werden"
|
||||||
},
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Lernanalyse",
|
||||||
|
"noData": "Keine Analysedaten vorhanden",
|
||||||
|
"participants": "Teilnehmer",
|
||||||
|
"messages": "Nachrichten",
|
||||||
|
"expand": "Details anzeigen",
|
||||||
|
"collapse": "Details ausblenden",
|
||||||
|
"deleteConfirm": "Analysedaten wirklich löschen?",
|
||||||
|
"deleted": "Analysedaten gelöscht",
|
||||||
|
"deleteFailed": "Fehler beim Löschen",
|
||||||
|
"userName": "Name",
|
||||||
|
"role": "Rolle",
|
||||||
|
"moderator": "Moderator",
|
||||||
|
"viewer": "Teilnehmer",
|
||||||
|
"talkTime": "Sprechzeit",
|
||||||
|
"webcamTime": "Webcam-Zeit",
|
||||||
|
"reactions": "Reaktionen"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
|
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
|
||||||
|
|||||||
@@ -255,7 +255,10 @@
|
|||||||
"shareRemoved": "Share removed",
|
"shareRemoved": "Share removed",
|
||||||
"shareFailed": "Share failed",
|
"shareFailed": "Share failed",
|
||||||
"shareRemove": "Remove share",
|
"shareRemove": "Remove share",
|
||||||
"defaultWelcome": "Welcome to the meeting!"
|
"defaultWelcome": "Welcome to the meeting!",
|
||||||
|
"analytics": "Learning Analytics",
|
||||||
|
"enableAnalytics": "Enable learning analytics",
|
||||||
|
"enableAnalyticsHint": "Collects participant engagement data after each meeting."
|
||||||
},
|
},
|
||||||
"recordings": {
|
"recordings": {
|
||||||
"title": "Recordings",
|
"title": "Recordings",
|
||||||
@@ -273,6 +276,24 @@
|
|||||||
"publish": "Publish",
|
"publish": "Publish",
|
||||||
"loadFailed": "Recordings could not be loaded"
|
"loadFailed": "Recordings could not be loaded"
|
||||||
},
|
},
|
||||||
|
"analytics": {
|
||||||
|
"title": "Learning Analytics",
|
||||||
|
"noData": "No analytics data available",
|
||||||
|
"participants": "Participants",
|
||||||
|
"messages": "Messages",
|
||||||
|
"expand": "Show details",
|
||||||
|
"collapse": "Hide details",
|
||||||
|
"deleteConfirm": "Really delete analytics data?",
|
||||||
|
"deleted": "Analytics data deleted",
|
||||||
|
"deleteFailed": "Error deleting data",
|
||||||
|
"userName": "Name",
|
||||||
|
"role": "Role",
|
||||||
|
"moderator": "Moderator",
|
||||||
|
"viewer": "Viewer",
|
||||||
|
"talkTime": "Talk time",
|
||||||
|
"webcamTime": "Webcam time",
|
||||||
|
"reactions": "Reactions"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"subtitle": "Manage your profile and settings",
|
"subtitle": "Manage your profile and settings",
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import {
|
|||||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||||
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
||||||
FileText, Upload, Trash2, Link,
|
FileText, Upload, Trash2, Link, BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Modal from '../components/Modal';
|
import Modal from '../components/Modal';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import RecordingList from '../components/RecordingList';
|
import RecordingList from '../components/RecordingList';
|
||||||
|
import AnalyticsList from '../components/AnalyticsList';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function RoomDetail() {
|
export default function RoomDetail() {
|
||||||
@@ -22,6 +23,7 @@ export default function RoomDetail() {
|
|||||||
const [room, setRoom] = useState(null);
|
const [room, setRoom] = useState(null);
|
||||||
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
||||||
const [recordings, setRecordings] = useState([]);
|
const [recordings, setRecordings] = useState([]);
|
||||||
|
const [analytics, setAnalytics] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState(null);
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
@@ -93,10 +95,20 @@ export default function RoomDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAnalytics = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/analytics/room/${uid}`);
|
||||||
|
setAnalytics(res.data.analytics || []);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRoom();
|
fetchRoom();
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchRecordings();
|
fetchRecordings();
|
||||||
|
fetchAnalytics();
|
||||||
const interval = setInterval(fetchStatus, 10000);
|
const interval = setInterval(fetchStatus, 10000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [uid]);
|
}, [uid]);
|
||||||
@@ -183,6 +195,7 @@ export default function RoomDetail() {
|
|||||||
record_meeting: !!editRoom.record_meeting,
|
record_meeting: !!editRoom.record_meeting,
|
||||||
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,
|
||||||
});
|
});
|
||||||
setRoom(res.data.room);
|
setRoom(res.data.room);
|
||||||
setEditRoom(res.data.room);
|
setEditRoom(res.data.room);
|
||||||
@@ -331,6 +344,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 },
|
||||||
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -528,6 +542,10 @@ export default function RoomDetail() {
|
|||||||
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'settings' && isOwner && editRoom && (
|
{activeTab === 'settings' && isOwner && editRoom && (
|
||||||
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
|
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
|
||||||
<div>
|
<div>
|
||||||
@@ -621,6 +639,15 @@ export default function RoomDetail() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
|
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!editRoom.learning_analytics}
|
||||||
|
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guest access section */}
|
{/* Guest access section */}
|
||||||
|
|||||||
Reference in New Issue
Block a user