This commit is contained in:
477
src/pages/RoomDetail.jsx
Normal file
477
src/pages/RoomDetail.jsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { useState, useEffect } 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,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import RecordingList from '../components/RecordingList';
|
||||
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 [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 isOwner = room && user && room.user_id === user.id;
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}`);
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
} 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
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoom();
|
||||
fetchStatus();
|
||||
fetchRecordings();
|
||||
const interval = setInterval(fetchStatus, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
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 () => {
|
||||
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,
|
||||
});
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
toast.success(t('room.settingsSaved'));
|
||||
} catch (err) {
|
||||
toast.error(t('room.settingsSaveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`);
|
||||
toast.success(t('room.linkCopied'));
|
||||
};
|
||||
|
||||
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 },
|
||||
...(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>
|
||||
)}
|
||||
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors">
|
||||
<Copy size={14} />
|
||||
{t('room.copyLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isOwner && !status.running && (
|
||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||
{t('room.start')}
|
||||
</button>
|
||||
)}
|
||||
{status.running && (
|
||||
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary">
|
||||
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||
{t('room.join')}
|
||||
</button>
|
||||
)}
|
||||
{isOwner && 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.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 === '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
|
||||
/>
|
||||
</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>
|
||||
</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>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editRoom.guest_access}
|
||||
onChange={e => setEditRoom({ ...editRoom, guest_access: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm text-th-text">{t('room.guestAccess')}</span>
|
||||
<p className="text-xs text-th-text-s">{t('room.guestAccessHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{editRoom.guest_access && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user