Files
redlight/src/pages/RoomDetail.jsx
Michelle 1690a74c19
All checks were successful
Build & Push Docker Image / build (push) Successful in 4m21s
feat: add email invitation functionality for guests with support for multiple addresses
2026-04-02 00:54:57 +02:00

939 lines
38 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 [fedEmails, setFedEmails] = 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();
const hasAddress = fedAddress.trim().length > 0;
const hasEmails = fedEmails.trim().length > 0;
if (!hasAddress && !hasEmails) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
if (hasAddress) {
// Federation address mode
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
setFedSending(false);
return;
}
await api.post('/federation/invite', {
room_uid: uid,
to: fedAddress,
message: fedMessage || undefined,
});
toast.success(t('federation.sent'));
} else {
// Email mode
const emailList = fedEmails.split(',').map(e => e.trim()).filter(Boolean);
if (emailList.length === 0) {
toast.error(t('federation.emailHint'));
setFedSending(false);
return;
}
await api.post('/rooms/invite-email', {
room_uid: uid,
emails: emailList,
message: fedMessage || undefined,
});
toast.success(t('federation.emailSent'));
}
setShowFedInvite(false);
setFedAddress('');
setFedEmails('');
setFedMessage('');
} catch (err) {
toast.error(err.response?.data?.error || t(hasAddress ? 'federation.sendFailed' : 'federation.emailSendFailed'));
} 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_file}
</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); if (e.target.value) setFedEmails(''); }}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
disabled={fedEmails.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div className="flex items-center gap-3 my-2">
<div className="flex-1 border-t border-th-border" />
<span className="text-xs text-th-text-s uppercase">{t('common.or')}</span>
<div className="flex-1 border-t border-th-border" />
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<input
type="text"
value={fedEmails}
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
className="input-field"
placeholder={t('federation.emailPlaceholder')}
disabled={fedAddress.trim().length > 0}
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.emailHint')}</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 || (!fedAddress.trim() && !fedEmails.trim())} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('federation.send')}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}