Init v1.0.0
Some checks failed
Build & Push Docker Image / build (push) Failing after 53s

This commit is contained in:
2026-02-24 18:14:16 +01:00
commit 54d6ee553a
49 changed files with 10410 additions and 0 deletions

477
src/pages/RoomDetail.jsx Normal file
View 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>
);
}