Merge remote-tracking branch 'refs/remotes/origin/main'
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m34s

This commit is contained in:
2026-03-16 13:32:45 +01:00
11 changed files with 1817 additions and 1868 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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',
}); });
} }

View File

@@ -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,
]); ]);

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 */}