This commit is contained in:
368
src/pages/Admin.jsx
Normal file
368
src/pages/Admin.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [resetPwModal, setResetPwModal] = useState(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
}, [user]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/users');
|
||||
setUsers(res.data.users);
|
||||
} catch {
|
||||
toast.error(t('admin.roleUpdateFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId, newRole) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||
toast.success(t('admin.roleUpdated'));
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (userId, userName) => {
|
||||
if (!confirm(t('admin.deleteUserConfirm', { name: userName }))) return;
|
||||
try {
|
||||
await api.delete(`/admin/users/${userId}`);
|
||||
toast.success(t('admin.userDeleted'));
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put(`/admin/users/${resetPwModal}/password`, { newPassword });
|
||||
toast.success(t('admin.passwordReset'));
|
||||
setResetPwModal(null);
|
||||
setNewPassword('');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.passwordResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreatingUser(true);
|
||||
try {
|
||||
await api.post('/admin/users', newUser);
|
||||
toast.success(t('admin.userCreated'));
|
||||
setShowCreateUser(false);
|
||||
setNewUser({ name: '', email: '', password: '', role: 'user' });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
|
||||
} finally {
|
||||
setCreatingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield size={24} className="text-th-accent" />
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('admin.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('admin.userCount', { count: users.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreateUser(true)} className="btn-primary">
|
||||
<UserPlus size={18} />
|
||||
<span className="hidden sm:inline">{t('admin.createUser')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="relative">
|
||||
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('admin.searchUsers')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-th-border">
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
||||
{t('admin.user')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden sm:table-cell">
|
||||
{t('admin.role')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden md:table-cell">
|
||||
{t('admin.rooms')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden lg:table-cell">
|
||||
{t('admin.registered')}
|
||||
</th>
|
||||
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
||||
{t('admin.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(u => (
|
||||
<tr key={u.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm 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.name[0]?.toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{u.name}</p>
|
||||
<p className="text-xs text-th-text-s">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden sm:table-cell">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
u.role === 'admin'
|
||||
? 'bg-th-accent/15 text-th-accent'
|
||||
: 'bg-th-bg-t text-th-text-s'
|
||||
}`}>
|
||||
{u.role === 'admin' ? <Shield size={10} /> : <Users size={10} />}
|
||||
{u.role === 'admin' ? t('admin.admin') : t('admin.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-th-text hidden md:table-cell">
|
||||
{u.room_count}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-th-text-s hidden lg:table-cell">
|
||||
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-end relative">
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === u.id ? null : u.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
disabled={u.id === user.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{openMenu === u.id && u.id !== user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
|
||||
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('admin.deleteUser')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||
<p className="text-th-text-s text-sm">{t('admin.noUsersFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset password modal */}
|
||||
{resetPwModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetPwModal(null)} />
|
||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
|
||||
<form onSubmit={handleResetPassword}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setResetPwModal(null)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create user modal */}
|
||||
{showCreateUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateUser(false)} />
|
||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.name}
|
||||
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="user">{t('admin.user')}</option>
|
||||
<option value="admin">{t('admin.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowCreateUser(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={creatingUser} className="btn-primary flex-1">
|
||||
{creatingUser ? <Loader2 size={18} className="animate-spin" /> : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
src/pages/Dashboard.jsx
Normal file
230
src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Video, Loader2, LayoutGrid, List } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import RoomCard from '../components/RoomCard';
|
||||
import Modal from '../components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useLanguage();
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newRoom, setNewRoom] = useState({
|
||||
name: '',
|
||||
welcome_message: '',
|
||||
max_participants: 0,
|
||||
access_code: '',
|
||||
mute_on_join: true,
|
||||
record_meeting: true,
|
||||
});
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const res = await api.get('/rooms');
|
||||
setRooms(res.data.rooms);
|
||||
} catch (err) {
|
||||
toast.error(t('dashboard.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/rooms', newRoom);
|
||||
toast.success(t('dashboard.roomCreated'));
|
||||
setShowCreate(false);
|
||||
setNewRoom({
|
||||
name: '',
|
||||
welcome_message: t('dashboard.welcomeMessageDefault'),
|
||||
max_participants: 0,
|
||||
access_code: '',
|
||||
mute_on_join: true,
|
||||
record_meeting: true,
|
||||
});
|
||||
fetchRooms();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('dashboard.roomCreateFailed'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (room) => {
|
||||
if (!confirm(t('dashboard.roomDeleteConfirm', { name: room.name }))) return;
|
||||
try {
|
||||
await api.delete(`/rooms/${room.uid}`);
|
||||
toast.success(t('dashboard.roomDeleted'));
|
||||
fetchRooms();
|
||||
} catch (err) {
|
||||
toast.error(t('dashboard.roomDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('dashboard.myRooms')}</h1>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{t('dashboard.roomCount', { count: rooms.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View toggle */}
|
||||
<div className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
viewMode === 'list' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
|
||||
}`}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
<Plus size={18} />
|
||||
<span className="hidden sm:inline">{t('dashboard.newRoom')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room grid/list */}
|
||||
{rooms.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<Video size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('dashboard.noRooms')}</h3>
|
||||
<p className="text-sm text-th-text-s mb-6">
|
||||
{t('dashboard.noRoomsSubtitle')}
|
||||
</p>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
<Plus size={18} />
|
||||
{t('dashboard.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{rooms.map(room => (
|
||||
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Room Modal */}
|
||||
{showCreate && (
|
||||
<Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoom.name}
|
||||
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('dashboard.roomNamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
|
||||
<textarea
|
||||
value={newRoom.welcome_message}
|
||||
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
|
||||
className="input-field resize-none"
|
||||
rows={2}
|
||||
placeholder={t('dashboard.welcomeMessageDefault')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRoom.max_participants}
|
||||
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
|
||||
className="input-field"
|
||||
min="0"
|
||||
placeholder={t('dashboard.maxParticipantsHint')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoom.access_code}
|
||||
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRoom.mute_on_join}
|
||||
onChange={e => setNewRoom({ ...newRoom, 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('dashboard.muteOnJoin')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRoom.record_meeting}
|
||||
onChange={e => setNewRoom({ ...newRoom, 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('dashboard.allowRecording')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="btn-primary flex-1">
|
||||
{creating ? <Loader2 size={18} className="animate-spin" /> : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/pages/GuestJoin.jsx
Normal file
222
src/pages/GuestJoin.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function GuestJoin() {
|
||||
const { uid } = useParams();
|
||||
const [roomInfo, setRoomInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [accessCode, setAccessCode] = useState('');
|
||||
const [moderatorCode, setModeratorCode] = useState('');
|
||||
const [status, setStatus] = useState({ running: false });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}/public`);
|
||||
setRoomInfo(res.data.room);
|
||||
setStatus({ running: res.data.running });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Raum nicht gefunden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRoom();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}/status`);
|
||||
setStatus(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
const handleJoin = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
toast.error('Name ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
||||
name: name.trim(),
|
||||
access_code: accessCode || undefined,
|
||||
moderator_code: moderatorCode || undefined,
|
||||
});
|
||||
if (res.data.joinUrl) {
|
||||
window.location.href = res.data.joinUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Beitritt fehlgeschlagen');
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-th-bg">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
|
||||
<div className="w-16 h-16 bg-th-error/15 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Video size={28} className="text-th-error" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-th-text mb-2">Zugang nicht möglich</h2>
|
||||
<p className="text-sm text-th-text-s mb-6">{error}</p>
|
||||
<Link to="/login" className="btn-primary inline-flex">
|
||||
Zum Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Join card */}
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-6">
|
||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
||||
<Video size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
|
||||
{/* Room info */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold text-th-text mb-1">{roomInfo.name}</h2>
|
||||
<p className="text-sm text-th-text-s">
|
||||
Erstellt von <span className="font-medium text-th-text">{roomInfo.owner_name}</span>
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: status.running ? 'rgba(34, 197, 94, 0.15)' : 'rgba(100, 116, 139, 0.15)',
|
||||
color: status.running ? '#22c55e' : '#94a3b8',
|
||||
}}
|
||||
>
|
||||
{status.running ? <Radio size={10} className="animate-pulse" /> : <Users size={12} />}
|
||||
{status.running ? 'Meeting läuft' : 'Noch nicht gestartet'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Join form */}
|
||||
<form onSubmit={handleJoin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">Ihr Name *</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Max Mustermann"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roomInfo.has_access_code && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">Zugangscode</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={accessCode}
|
||||
onChange={e => setAccessCode(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Code eingeben"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">
|
||||
Moderator-Code
|
||||
<span className="text-th-text-s font-normal ml-1">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={moderatorCode}
|
||||
onChange={e => setModeratorCode(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Nur wenn Sie Moderator sind"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={joining || (!status.running && !roomInfo.anyone_can_start)}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{joining ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Meeting beitreten
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!status.running && (
|
||||
<p className="text-xs text-th-text-s text-center">
|
||||
Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
||||
Haben Sie ein Konto? <span className="text-th-accent font-medium">Anmelden</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/pages/Home.jsx
Normal file
146
src/pages/Home.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Video,
|
||||
title: t('home.featureVideoTitle'),
|
||||
desc: t('home.featureVideoDesc'),
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: t('home.featureRoomsTitle'),
|
||||
desc: t('home.featureRoomsDesc'),
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: t('home.featureUsersTitle'),
|
||||
desc: t('home.featureUsersDesc'),
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: t('home.featureThemesTitle'),
|
||||
desc: t('home.featureThemesDesc'),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: t('home.featureRecordingsTitle'),
|
||||
desc: t('home.featureRecordingsDesc'),
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: t('home.featureOpenSourceTitle'),
|
||||
desc: t('home.featureOpenSourceDesc'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-th-bg">
|
||||
{/* Hero Section */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Background gradient */}
|
||||
<div className="absolute inset-0 gradient-bg opacity-5" />
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] gradient-bg opacity-10 blur-3xl rounded-full" />
|
||||
|
||||
{/* Navbar */}
|
||||
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 gradient-bg rounded-lg flex items-center justify-center">
|
||||
<Video size={20} className="text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/login" className="btn-ghost text-sm">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
<Link to="/register" className="btn-primary text-sm">
|
||||
{t('auth.register')}
|
||||
<ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero content */}
|
||||
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6">
|
||||
<Zap size={14} />
|
||||
{t('home.poweredBy')}
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
|
||||
{t('home.heroTitle')}{' '}
|
||||
<span className="gradient-text">{t('home.heroTitleHighlight')}</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-th-text-s max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
{t('home.heroSubtitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 justify-center">
|
||||
<Link to="/register" className="btn-primary text-base px-8 py-3">
|
||||
{t('home.getStarted')}
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<Link to="/login" className="btn-secondary text-base px-8 py-3">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-center gap-8 md:gap-16 mt-16">
|
||||
{[
|
||||
{ value: '15+', label: t('home.statThemes') },
|
||||
{ value: '∞', label: t('home.statRooms') },
|
||||
{ value: '100%', label: t('home.statOpenSource') },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<div className="text-2xl md:text-3xl font-bold gradient-text">{stat.value}</div>
|
||||
<div className="text-sm text-th-text-s mt-1">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<div className="max-w-6xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
|
||||
{t('home.features')}
|
||||
</h2>
|
||||
<p className="text-lg text-th-text-s max-w-2xl mx-auto">
|
||||
{t('home.featuresSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature, idx) => (
|
||||
<div key={idx} className="card p-6 hover:shadow-th-lg transition-all duration-300 group">
|
||||
<div className="w-12 h-12 rounded-xl gradient-bg/10 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
|
||||
style={{ background: `linear-gradient(135deg, var(--gradient-start), var(--gradient-end))`, opacity: 0.15 }}>
|
||||
<feature.icon size={24} className="text-th-accent" style={{ opacity: 1 }} />
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 -mt-16 relative">
|
||||
<feature.icon size={24} className="text-th-accent" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-th-border py-8 text-center">
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('home.footer', { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/pages/Login.jsx
Normal file
120
src/pages/Login.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Video, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.loginFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
||||
<Video size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
{t('auth.loginSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.login')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/Register.jsx
Normal file
165
src/pages/Register.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Video, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t('auth.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast.error(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(name, email, password);
|
||||
toast.success(t('auth.registerSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.registerFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register card */}
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
||||
<Video size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.createAccount')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
{t('auth.registerSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.repeatPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.register')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.hasAccount')}{' '}
|
||||
<Link to="/login" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signInNow')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
406
src/pages/Settings.jsx
Normal file
406
src/pages/Settings.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { themes, getThemeGroups } from '../themes';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Settings() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, language, setLanguage } = useLanguage();
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
});
|
||||
const [passwords, setPasswords] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState('profile');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const groups = getThemeGroups();
|
||||
|
||||
const avatarColors = [
|
||||
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
||||
'#ec4899', '#f43f5e', '#ef4444', '#f97316',
|
||||
'#eab308', '#22c55e', '#14b8a6', '#06b6d4',
|
||||
'#3b82f6', '#2563eb', '#7c3aed', '#64748b',
|
||||
];
|
||||
|
||||
const handleProfileSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const res = await api.put('/auth/profile', {
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
theme,
|
||||
avatar_color: user?.avatar_color,
|
||||
});
|
||||
updateUser(res.data.user);
|
||||
toast.success(t('settings.profileSaved'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.profileSaveFailed'));
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (passwords.newPassword !== passwords.confirmPassword) {
|
||||
toast.error(t('settings.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await api.put('/auth/password', {
|
||||
currentPassword: passwords.currentPassword,
|
||||
newPassword: passwords.newPassword,
|
||||
});
|
||||
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
toast.success(t('settings.passwordChanged'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.passwordChangeFailed'));
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarColor = async (color) => {
|
||||
try {
|
||||
const res = await api.put('/auth/profile', { avatar_color: color });
|
||||
updateUser(res.data.user);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error(t('settings.avatarInvalidType'));
|
||||
return;
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error(t('settings.avatarTooLarge'));
|
||||
return;
|
||||
}
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const res = await api.post('/auth/avatar', file, {
|
||||
headers: { 'Content-Type': file.type },
|
||||
});
|
||||
updateUser(res.data.user);
|
||||
toast.success(t('settings.avatarUploaded'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.avatarUploadFailed'));
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
const res = await api.delete('/auth/avatar');
|
||||
updateUser(res.data.user);
|
||||
toast.success(t('settings.avatarRemoved'));
|
||||
} catch {
|
||||
toast.error(t('settings.avatarRemoveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ id: 'profile', label: t('settings.profile'), icon: User },
|
||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||
{ id: 'language', label: t('settings.language'), icon: Globe },
|
||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-th-text-s mt-1">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Section nav */}
|
||||
<div className="md:w-56 flex-shrink-0">
|
||||
<nav className="flex md:flex-col gap-1">
|
||||
{sections.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeSection === s.id
|
||||
? 'bg-th-accent text-th-accent-t'
|
||||
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
||||
}`}
|
||||
>
|
||||
<s.icon size={16} />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 max-w-2xl">
|
||||
{/* Profile section */}
|
||||
{activeSection === 'profile' && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.editProfile')}</h2>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatar')}</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative group">
|
||||
{user?.avatar_image ? (
|
||||
<img
|
||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||
alt="Avatar"
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
|
||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
>
|
||||
{uploadingAvatar ? (
|
||||
<Loader2 size={20} className="text-white animate-spin" />
|
||||
) : (
|
||||
<Camera size={20} className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn-ghost text-xs py-1.5 px-3"
|
||||
>
|
||||
<Camera size={14} />
|
||||
{t('settings.uploadImage')}
|
||||
</button>
|
||||
{user?.avatar_image && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAvatarRemove}
|
||||
className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('settings.removeImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s">{t('settings.avatarHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avatar Color (fallback) */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{avatarColors.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => handleAvatarColor(color)}
|
||||
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
|
||||
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
|
||||
}`}
|
||||
style={{ backgroundColor: color, '--tw-ring-offset-color': 'var(--bg-primary)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mt-2">{t('settings.avatarColorHint')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleProfileSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={profile.name}
|
||||
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={e => setProfile({ ...profile, email: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={savingProfile} className="btn-primary">
|
||||
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password section */}
|
||||
{activeSection === 'password' && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
|
||||
<form onSubmit={handlePasswordSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.currentPassword}
|
||||
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.newPassword}
|
||||
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.confirmPassword}
|
||||
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.repeatPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={savingPassword} className="btn-primary">
|
||||
{savingPassword ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
{t('settings.changePassword')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language section */}
|
||||
{activeSection === 'language' && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-2">{t('settings.selectLanguage')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-6">{t('settings.subtitle')}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
].map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => setLanguage(lang.code)}
|
||||
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all ${
|
||||
language === lang.code
|
||||
? 'border-th-accent shadow-md bg-th-accent/5'
|
||||
: 'border-transparent hover:border-th-border'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{lang.flag}</span>
|
||||
<span className="text-sm font-medium text-th-text">{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Themes section */}
|
||||
{activeSection === 'themes' && (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groups).map(([groupName, groupThemes]) => (
|
||||
<div key={groupName} className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text-s uppercase tracking-wider mb-4">{groupName}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{groupThemes.map(th => (
|
||||
<button
|
||||
key={th.id}
|
||||
onClick={() => setTheme(th.id)}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
|
||||
theme === th.id
|
||||
? 'border-th-accent shadow-md'
|
||||
: 'border-transparent hover:border-th-border'
|
||||
}`}
|
||||
>
|
||||
{/* Color preview */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border"
|
||||
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-th-text">{th.name}</p>
|
||||
<p className="text-xs text-th-text-s capitalize">{th.type === 'light' ? t('themes.light') : t('themes.dark')}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user