All checks were successful
Build & Push Docker Image / build (push) Successful in 5m11s
- Added `analytics_visibility` column to `rooms` table to control who can view analytics data. - Updated analytics routes to check visibility settings before allowing access and export of analytics data. - Implemented export functionality for analytics in CSV, XLSX, and PDF formats. - Enhanced `AnalyticsList` component to include export options for analytics entries. - Updated room detail page to allow setting analytics visibility when creating or editing rooms. - Added translations for new analytics visibility options and export messages.
892 lines
37 KiB
JavaScript
892 lines
37 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
|
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
|
Shield, Save, UserPlus, X, Share2, Globe, Send,
|
|
FileText, Upload, Trash2, Link, BarChart3,
|
|
} from 'lucide-react';
|
|
import Modal from '../components/Modal';
|
|
import api from '../services/api';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import RecordingList from '../components/RecordingList';
|
|
import AnalyticsList from '../components/AnalyticsList';
|
|
import toast from 'react-hot-toast';
|
|
|
|
export default function RoomDetail() {
|
|
const { uid } = useParams();
|
|
const navigate = useNavigate();
|
|
const { user } = useAuth();
|
|
const { t } = useLanguage();
|
|
|
|
const [room, setRoom] = useState(null);
|
|
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
|
const [recordings, setRecordings] = useState([]);
|
|
const [analytics, setAnalytics] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState(null);
|
|
const [activeTab, setActiveTab] = useState('overview');
|
|
const [editRoom, setEditRoom] = useState(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [sharedUsers, setSharedUsers] = useState([]);
|
|
const [shareSearch, setShareSearch] = useState('');
|
|
const [shareResults, setShareResults] = useState([]);
|
|
const [shareSearching, setShareSearching] = useState(false);
|
|
const [waitingToJoin, setWaitingToJoin] = useState(false);
|
|
const prevRunningRef = useRef(false);
|
|
const [showCopyMenu, setShowCopyMenu] = useState(false);
|
|
const copyMenuRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (copyMenuRef.current && !copyMenuRef.current.contains(e.target)) {
|
|
setShowCopyMenu(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// Federation invite state
|
|
const [showFedInvite, setShowFedInvite] = useState(false);
|
|
const [fedAddress, setFedAddress] = useState('');
|
|
const [fedMessage, setFedMessage] = useState('');
|
|
const [fedSending, setFedSending] = useState(false);
|
|
|
|
// Presentation state
|
|
const [uploadingPresentation, setUploadingPresentation] = useState(false);
|
|
const [removingPresentation, setRemovingPresentation] = useState(false);
|
|
const presentationInputRef = useRef(null);
|
|
|
|
const isOwner = room && user && room.user_id === user.id;
|
|
const isShared = room && !!room.shared;
|
|
const canManage = isOwner || isShared;
|
|
|
|
const fetchRoom = async () => {
|
|
try {
|
|
const res = await api.get(`/rooms/${uid}`);
|
|
setRoom(res.data.room);
|
|
setEditRoom(res.data.room);
|
|
if (res.data.sharedUsers) setSharedUsers(res.data.sharedUsers);
|
|
} catch {
|
|
toast.error(t('room.notFound'));
|
|
navigate('/dashboard');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchStatus = async () => {
|
|
try {
|
|
const res = await api.get(`/rooms/${uid}/status`);
|
|
setStatus(res.data);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
};
|
|
|
|
const fetchRecordings = async () => {
|
|
try {
|
|
const res = await api.get(`/recordings/room/${uid}`);
|
|
setRecordings(res.data.recordings || []);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
};
|
|
|
|
const fetchAnalytics = async () => {
|
|
try {
|
|
const res = await api.get(`/analytics/room/${uid}`);
|
|
setAnalytics(res.data.analytics || []);
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchRoom();
|
|
fetchStatus();
|
|
fetchRecordings();
|
|
fetchAnalytics();
|
|
const interval = setInterval(fetchStatus, 10000);
|
|
return () => clearInterval(interval);
|
|
}, [uid]);
|
|
|
|
// Auto-join when meeting starts while waiting
|
|
useEffect(() => {
|
|
if (!prevRunningRef.current && status.running && waitingToJoin) {
|
|
new Audio('/sounds/meeting-started.mp3').play().catch(() => {});
|
|
toast.success(t('room.meetingStarted'));
|
|
setWaitingToJoin(false);
|
|
setActionLoading('join');
|
|
api.post(`/rooms/${uid}/join`, {})
|
|
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
|
|
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
|
|
.finally(() => setActionLoading(null));
|
|
}
|
|
prevRunningRef.current = status.running;
|
|
}, [status.running]);
|
|
|
|
const handleStart = async () => {
|
|
setActionLoading('start');
|
|
try {
|
|
const res = await api.post(`/rooms/${uid}/start`);
|
|
if (res.data.joinUrl) {
|
|
window.open(res.data.joinUrl, '_blank');
|
|
}
|
|
setTimeout(fetchStatus, 2000);
|
|
toast.success(t('room.meetingStarted'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('room.meetingStartFailed'));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleJoin = async () => {
|
|
if (!status.running) {
|
|
setWaitingToJoin(true);
|
|
toast(t('room.guestWaitingTitle'), { icon: '🕐' });
|
|
return;
|
|
}
|
|
setWaitingToJoin(false);
|
|
setActionLoading('join');
|
|
try {
|
|
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
|
|
const res = await api.post(`/rooms/${uid}/join`, data);
|
|
if (res.data.joinUrl) {
|
|
window.open(res.data.joinUrl, '_blank');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('room.joinFailed'));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleEnd = async () => {
|
|
if (!confirm(t('room.endConfirm'))) return;
|
|
setActionLoading('end');
|
|
try {
|
|
await api.post(`/rooms/${uid}/end`);
|
|
toast.success(t('room.meetingEnded'));
|
|
setTimeout(fetchStatus, 2000);
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('room.meetingEndFailed'));
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleSaveSettings = async (e) => {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
try {
|
|
const res = await api.put(`/rooms/${uid}`, {
|
|
name: editRoom.name,
|
|
welcome_message: editRoom.welcome_message,
|
|
max_participants: editRoom.max_participants,
|
|
access_code: editRoom.access_code,
|
|
mute_on_join: !!editRoom.mute_on_join,
|
|
require_approval: !!editRoom.require_approval,
|
|
anyone_can_start: !!editRoom.anyone_can_start,
|
|
all_join_moderator: !!editRoom.all_join_moderator,
|
|
record_meeting: !!editRoom.record_meeting,
|
|
guest_access: !!editRoom.guest_access,
|
|
moderator_code: editRoom.moderator_code,
|
|
learning_analytics: !!editRoom.learning_analytics,
|
|
analytics_visibility: editRoom.analytics_visibility || 'owner',
|
|
});
|
|
setRoom(res.data.room);
|
|
setEditRoom(res.data.room);
|
|
toast.success(t('room.settingsSaved'));
|
|
} catch (err) {
|
|
toast.error(t('room.settingsSaveFailed'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const copyToClipboard = (url) => {
|
|
navigator.clipboard.writeText(url);
|
|
toast.success(t('room.linkCopied'));
|
|
setShowCopyMenu(false);
|
|
};
|
|
|
|
// Federation invite handler
|
|
const handlePresentationUpload = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const allowedTypes = [
|
|
'application/pdf',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'application/vnd.oasis.opendocument.presentation',
|
|
'application/msword',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
toast.error('Unsupported file type. Allowed: PDF, PPT, PPTX, ODP, DOC, DOCX');
|
|
return;
|
|
}
|
|
setUploadingPresentation(true);
|
|
try {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const res = await api.post(`/rooms/${uid}/presentation`, arrayBuffer, {
|
|
headers: {
|
|
'Content-Type': file.type,
|
|
'X-Filename': encodeURIComponent(file.name),
|
|
},
|
|
});
|
|
setRoom(res.data.room);
|
|
setEditRoom(res.data.room);
|
|
toast.success(t('room.presentationUploaded'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('room.presentationUploadFailed'));
|
|
} finally {
|
|
setUploadingPresentation(false);
|
|
if (presentationInputRef.current) presentationInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handlePresentationRemove = async () => {
|
|
setRemovingPresentation(true);
|
|
try {
|
|
const res = await api.delete(`/rooms/${uid}/presentation`);
|
|
setRoom(res.data.room);
|
|
setEditRoom(res.data.room);
|
|
toast.success(t('room.presentationRemoved'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('room.presentationRemoveFailed'));
|
|
} finally {
|
|
setRemovingPresentation(false);
|
|
}
|
|
};
|
|
|
|
const handleFedInvite = async (e) => {
|
|
e.preventDefault();
|
|
// Accept @user@domain or user@domain — must have a domain part
|
|
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
|
|
if (!normalized.includes('@') || normalized.endsWith('@')) {
|
|
toast.error(t('federation.addressHint'));
|
|
return;
|
|
}
|
|
setFedSending(true);
|
|
try {
|
|
await api.post('/federation/invite', {
|
|
room_uid: uid,
|
|
to: fedAddress,
|
|
message: fedMessage || undefined,
|
|
});
|
|
toast.success(t('federation.sent'));
|
|
setShowFedInvite(false);
|
|
setFedAddress('');
|
|
setFedMessage('');
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('federation.sendFailed'));
|
|
} finally {
|
|
setFedSending(false);
|
|
}
|
|
};
|
|
|
|
// Share functions
|
|
const searchUsers = async (query) => {
|
|
setShareSearch(query);
|
|
if (query.length < 2) {
|
|
setShareResults([]);
|
|
return;
|
|
}
|
|
setShareSearching(true);
|
|
try {
|
|
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
|
|
// Filter out already shared users
|
|
const sharedIds = new Set(sharedUsers.map(u => u.id));
|
|
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
|
|
} catch {
|
|
setShareResults([]);
|
|
} finally {
|
|
setShareSearching(false);
|
|
}
|
|
};
|
|
|
|
const handleShare = async (userId) => {
|
|
try {
|
|
const res = await api.post(`/rooms/${uid}/shares`, { user_id: userId });
|
|
setSharedUsers(res.data.shares);
|
|
setShareSearch('');
|
|
setShareResults([]);
|
|
toast.success(t('room.shareAdded'));
|
|
} catch (err) {
|
|
toast.error(err.response?.data?.error || t('room.shareFailed'));
|
|
}
|
|
};
|
|
|
|
const handleUnshare = async (userId) => {
|
|
try {
|
|
const res = await api.delete(`/rooms/${uid}/shares/${userId}`);
|
|
setSharedUsers(res.data.shares);
|
|
toast.success(t('room.shareRemoved'));
|
|
} catch {
|
|
toast.error(t('room.shareFailed'));
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 size={32} className="animate-spin text-th-accent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!room) return null;
|
|
|
|
const tabs = [
|
|
{ id: 'overview', label: t('room.overview'), icon: Play },
|
|
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.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 }] : []),
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{/* Back button */}
|
|
<button
|
|
onClick={() => navigate('/dashboard')}
|
|
className="btn-ghost text-sm mb-4"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
{t('room.backToDashboard')}
|
|
</button>
|
|
|
|
{/* Room header */}
|
|
<div className="card p-6 mb-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<h1 className="text-2xl font-bold text-th-text">{room.name}</h1>
|
|
{status.running && (
|
|
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-th-success/15 text-th-success rounded-full text-xs font-semibold">
|
|
<Radio size={12} className="animate-pulse" />
|
|
{t('common.live')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-th-text-s">
|
|
<span className="flex items-center gap-1">
|
|
<Users size={14} />
|
|
{status.running ? t('room.participants', { count: status.participantCount }) : t('common.offline')}
|
|
</span>
|
|
{room.access_code && (
|
|
<span className="flex items-center gap-1">
|
|
<Lock size={14} />
|
|
{t('common.protected')}
|
|
</span>
|
|
)}
|
|
<div className="relative" ref={copyMenuRef}>
|
|
<button
|
|
onClick={() => setShowCopyMenu(v => !v)}
|
|
className="flex items-center gap-1 hover:text-th-accent transition-colors"
|
|
>
|
|
<Copy size={14} />
|
|
{t('room.copyLink')}
|
|
</button>
|
|
{showCopyMenu && (
|
|
<div className="absolute bottom-full left-0 mb-2 bg-th-surface border border-th-border rounded-lg shadow-lg z-50 min-w-[160px] py-1">
|
|
<button
|
|
onClick={() => copyToClipboard(`${window.location.origin}/rooms/${uid}`)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
|
>
|
|
<Link size={12} />
|
|
{t('room.copyRoomLink')}
|
|
</button>
|
|
<button
|
|
onClick={() => copyToClipboard(`${window.location.origin}/join/${uid}`)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-th-text hover:bg-th-accent/10 transition-colors"
|
|
>
|
|
<Users size={12} />
|
|
{t('room.copyGuestLink')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{canManage && (
|
|
<button
|
|
onClick={() => setShowFedInvite(true)}
|
|
className="btn-ghost text-sm"
|
|
title={t('federation.inviteRemote')}
|
|
>
|
|
<Globe size={16} />
|
|
<span className="hidden sm:inline">{t('federation.inviteRemote')}</span>
|
|
</button>
|
|
)}
|
|
{canManage && !status.running && !waitingToJoin && (
|
|
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
|
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
|
{t('room.start')}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={waitingToJoin ? () => setWaitingToJoin(false) : handleJoin}
|
|
disabled={actionLoading === 'join'}
|
|
className={waitingToJoin ? 'btn-ghost' : 'btn-primary'}
|
|
title={waitingToJoin ? t('room.guestCancelWaiting') : undefined}
|
|
>
|
|
{(actionLoading === 'join' || waitingToJoin) ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
|
{waitingToJoin ? t('room.waitingToJoin') : t('room.join')}
|
|
</button>
|
|
{canManage && status.running && (
|
|
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
|
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
|
{t('room.end')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
|
{tabs.filter(tab => !tab.hidden).map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${activeTab === tab.id
|
|
? 'border-th-accent text-th-accent'
|
|
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
|
}`}
|
|
>
|
|
<tab.icon size={16} />
|
|
{tab.label}
|
|
{tab.count !== undefined && tab.count > 0 && (
|
|
<span className="px-1.5 py-0.5 rounded-full bg-th-accent/15 text-th-accent text-xs">
|
|
{tab.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{activeTab === 'overview' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Meeting info */}
|
|
<div className="card p-5">
|
|
<h3 className="text-sm font-semibold text-th-text mb-4">{t('room.meetingDetails')}</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-th-text-s">{t('room.meetingId')}</span>
|
|
<code className="bg-th-bg-s px-2 py-0.5 rounded text-xs text-th-text font-mono">{room.uid}</code>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-th-text-s">{t('room.status')}</span>
|
|
<span className={status.running ? 'text-th-success font-medium' : 'text-th-text-s'}>
|
|
{status.running ? t('common.active') : t('common.inactive')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-th-text-s">{t('room.maxParticipants')}</span>
|
|
<span className="text-th-text">{room.max_participants || t('common.unlimited')}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-th-text-s">{t('room.accessCode')}</span>
|
|
<span className="text-th-text">{room.access_code || t('common.none')}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Room settings overview */}
|
|
<div className="card p-5">
|
|
<h3 className="text-sm font-semibold text-th-text mb-4">{t('room.roomSettings')}</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3 text-sm">
|
|
{room.mute_on_join ? <MicOff size={16} className="text-th-warning" /> : <Mic size={16} className="text-th-success" />}
|
|
<span className="text-th-text">
|
|
{room.mute_on_join ? t('room.mutedOnJoin') : t('room.micActiveOnJoin')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<UserCheck size={16} className={room.require_approval ? 'text-th-warning' : 'text-th-text-s'} />
|
|
<span className="text-th-text">
|
|
{room.require_approval ? t('room.approvalRequired') : t('room.freeJoin')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<Shield size={16} className={room.all_join_moderator ? 'text-th-accent' : 'text-th-text-s'} />
|
|
<span className="text-th-text">
|
|
{room.all_join_moderator ? t('room.allModerators') : t('room.rolesAssigned')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-sm">
|
|
<FileVideo size={16} className={room.record_meeting ? 'text-th-success' : 'text-th-text-s'} />
|
|
<span className="text-th-text">
|
|
{room.record_meeting ? t('room.recordingAllowed') : t('room.recordingDisabled')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Welcome message */}
|
|
<div className="card p-5 md:col-span-2">
|
|
<h3 className="text-sm font-semibold text-th-text mb-2">{t('room.welcomeMsg')}</h3>
|
|
<p className="text-sm text-th-text-s">{room.welcome_message || '—'}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'recordings' && (
|
|
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
|
)}
|
|
|
|
{activeTab === 'analytics' && (
|
|
<AnalyticsList analytics={analytics} onRefresh={fetchAnalytics} isOwner={isOwner} />
|
|
)}
|
|
|
|
{activeTab === 'settings' && isOwner && editRoom && (
|
|
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
|
|
<input
|
|
type="text"
|
|
value={editRoom.name}
|
|
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
|
|
className="input-field"
|
|
required
|
|
minLength={2}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
|
|
<textarea
|
|
value={editRoom.welcome_message || ''}
|
|
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
|
|
className="input-field resize-none"
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
|
|
<input
|
|
type="number"
|
|
value={editRoom.max_participants}
|
|
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
|
|
className="input-field"
|
|
min="0"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
|
|
<input
|
|
type="text"
|
|
value={editRoom.access_code || ''}
|
|
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
|
|
className="input-field"
|
|
placeholder={t('room.emptyNoCode')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 pt-2">
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!editRoom.mute_on_join}
|
|
onChange={e => setEditRoom({ ...editRoom, mute_on_join: 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.muteOnJoin')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!editRoom.require_approval}
|
|
onChange={e => setEditRoom({ ...editRoom, require_approval: 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.requireApproval')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!editRoom.anyone_can_start}
|
|
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: 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.anyoneCanStart')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!editRoom.all_join_moderator}
|
|
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: 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.allJoinModerator')}</span>
|
|
</label>
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={!!editRoom.record_meeting}
|
|
onChange={e => setEditRoom({ ...editRoom, record_meeting: 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.allowRecording')}</span>
|
|
</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>
|
|
{!!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>
|
|
|
|
{/* Guest access section */}
|
|
<div className="pt-4 border-t border-th-border space-y-4">
|
|
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
|
|
<input
|
|
type="text"
|
|
value={editRoom.moderator_code || ''}
|
|
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
|
|
className="input-field"
|
|
placeholder={t('room.moderatorCodeHint')}
|
|
/>
|
|
<p className="text-xs text-th-text-s mt-1">{t('room.moderatorCodeDesc')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestLink')}</label>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 bg-th-bg-s px-3 py-2 rounded-lg text-xs text-th-text font-mono truncate border border-th-border">
|
|
{window.location.origin}/join/{room.uid}
|
|
</code>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(`${window.location.origin}/join/${room.uid}`);
|
|
toast.success(t('room.linkCopied'));
|
|
}}
|
|
className="btn-ghost text-xs py-2 px-3"
|
|
>
|
|
<Copy size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Presentation section */}
|
|
<div className="pt-4 border-t border-th-border space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2 mb-1">
|
|
<FileText size={16} />
|
|
{t('room.presentationTitle')}
|
|
</h3>
|
|
<p className="text-xs text-th-text-s">{t('room.presentationDesc')}</p>
|
|
</div>
|
|
|
|
{room.presentation_file ? (
|
|
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<FileText size={16} className="text-th-accent flex-shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
|
|
<p className="text-sm text-th-text font-medium truncate">
|
|
{room.presentation_name || `presentation.${room.presentation_file?.split('.').pop()}`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handlePresentationRemove}
|
|
disabled={removingPresentation}
|
|
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3"
|
|
>
|
|
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
|
|
{t('room.presentationRemove')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-th-text-s italic">{/* no presentation */}</div>
|
|
)}
|
|
|
|
<input
|
|
ref={presentationInputRef}
|
|
type="file"
|
|
accept=".pdf,.ppt,.pptx,.odp,.doc,.docx"
|
|
className="hidden"
|
|
onChange={handlePresentationUpload}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => presentationInputRef.current?.click()}
|
|
disabled={uploadingPresentation}
|
|
className="btn-secondary text-sm flex items-center gap-2"
|
|
>
|
|
{uploadingPresentation ? <Loader2 size={15} className="animate-spin" /> : <Upload size={15} />}
|
|
{t('room.presentationUpload')}
|
|
</button>
|
|
<p className="text-xs text-th-text-s">{t('room.presentationAllowedTypes')}</p>
|
|
</div>
|
|
|
|
{/* Share section */}
|
|
<div className="pt-4 border-t border-th-border space-y-4">
|
|
<h3 className="text-sm font-semibold text-th-text flex items-center gap-2">
|
|
<Share2 size={16} />
|
|
{t('room.shareTitle')}
|
|
</h3>
|
|
<p className="text-xs text-th-text-s">{t('room.shareDescription')}</p>
|
|
|
|
{/* User search */}
|
|
<div className="relative">
|
|
<div className="relative">
|
|
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
|
<input
|
|
type="text"
|
|
value={shareSearch}
|
|
onChange={e => searchUsers(e.target.value)}
|
|
className="input-field pl-11"
|
|
placeholder={t('room.shareSearchPlaceholder')}
|
|
/>
|
|
</div>
|
|
{shareResults.length > 0 && (
|
|
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
|
{shareResults.map(u => (
|
|
<button
|
|
key={u.id}
|
|
type="button"
|
|
onClick={() => handleShare(u.id)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
|
|
>
|
|
<div
|
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
|
>
|
|
{u.avatar_image ? (
|
|
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
|
|
) : (
|
|
(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
|
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Shared users list */}
|
|
{sharedUsers.length > 0 && (
|
|
<div className="space-y-2">
|
|
{sharedUsers.map(u => (
|
|
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div
|
|
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
|
|
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
|
>
|
|
{u.avatar_image ? (
|
|
<img src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`} alt="" className="w-full h-full object-cover" />
|
|
) : (
|
|
(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
|
|
<div className="text-xs text-th-text-s truncate">{u.email}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleUnshare(u.id)}
|
|
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
|
|
title={t('room.shareRemove')}
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-4 border-t border-th-border">
|
|
<button type="submit" disabled={saving} className="btn-primary">
|
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
|
{t('common.save')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Federation Invite Modal */}
|
|
{showFedInvite && (
|
|
<Modal title={t('federation.inviteTitle')} onClose={() => setShowFedInvite(false)}>
|
|
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
|
|
<form onSubmit={handleFedInvite} className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
|
|
<input
|
|
type="text"
|
|
value={fedAddress}
|
|
onChange={e => setFedAddress(e.target.value)}
|
|
className="input-field"
|
|
placeholder={t('federation.addressPlaceholder')}
|
|
required
|
|
/>
|
|
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
|
|
<textarea
|
|
value={fedMessage}
|
|
onChange={e => setFedMessage(e.target.value)}
|
|
className="input-field resize-none"
|
|
rows={2}
|
|
placeholder={t('federation.messagePlaceholder')}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
|
|
<button type="button" onClick={() => setShowFedInvite(false)} className="btn-secondary flex-1">
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
|
|
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
|
{t('federation.send')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|