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

31
src/components/Layout.jsx Normal file
View File

@@ -0,0 +1,31 @@
import { Outlet } from 'react-router-dom';
import { useState } from 'react';
import Navbar from './Navbar';
import Sidebar from './Sidebar';
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-th-bg flex">
{/* Sidebar */}
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main content */}
<div className="flex-1 flex flex-col min-h-screen lg:ml-64">
<Navbar onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-7xl w-full mx-auto">
<Outlet />
</main>
</div>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
</div>
);
}

25
src/components/Modal.jsx Normal file
View File

@@ -0,0 +1,25 @@
import { X } from 'lucide-react';
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
return (
<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={onClose} />
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} overflow-hidden`}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
<h2 className="text-lg font-semibold text-th-text">{title}</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6">
{children}
</div>
</div>
</div>
);
}

118
src/components/Navbar.jsx Normal file
View File

@@ -0,0 +1,118 @@
import { Menu, Search, LogOut, User } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate } from 'react-router-dom';
import { useState, useRef, useEffect } from 'react';
import api from '../services/api';
export default function Navbar({ onMenuClick }) {
const { user, logout } = useAuth();
const { t } = useLanguage();
const navigate = useNavigate();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
function handleClick(e) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const handleLogout = () => {
logout();
navigate('/');
};
const initials = user?.name
? user.name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
: '?';
return (
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-sm">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
{/* Left section */}
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<Menu size={20} />
</button>
{/* Search */}
<div className="hidden md:flex items-center gap-2 bg-th-bg-s border border-th-border rounded-lg px-3 py-2 w-64 lg:w-80">
<Search size={16} className="text-th-text-s flex-shrink-0" />
<input
type="text"
placeholder={t('common.search')}
className="bg-transparent border-none outline-none text-sm text-th-text placeholder-th-text-s w-full"
/>
</div>
</div>
{/* Right section */}
<div className="flex items-center gap-2">
{/* User dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold overflow-hidden"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
initials
)}
</div>
<span className="hidden md:block text-sm font-medium text-th-text">
{user?.name}
</span>
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
<div className="px-4 py-3 border-b border-th-border">
<p className="text-sm font-medium text-th-text">{user?.name}</p>
<p className="text-xs text-th-text-s">{user?.email}</p>
</div>
<div className="py-1">
<button
onClick={() => { navigate('/settings'); setDropdownOpen(false); }}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
>
<User size={16} />
{t('nav.settings')}
</button>
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
>
<LogOut size={16} />
{t('auth.logout')}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="min-h-screen bg-th-bg flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-th-accent border-t-transparent" />
</div>
);
}
if (!user) {
return <Navigate to="/login" />;
}
return children;
}

View File

@@ -0,0 +1,159 @@
import { Play, Trash2, Eye, EyeOff, Download, Clock, Users, FileVideo } from 'lucide-react';
import { useState } from 'react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function RecordingList({ recordings, onRefresh }) {
const [loading, setLoading] = useState({});
const { t, language } = useLanguage();
const formatDuration = (startTime, endTime) => {
if (!startTime || !endTime) return '—';
const ms = parseInt(endTime) - parseInt(startTime);
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
};
const formatDate = (timestamp) => {
if (!timestamp) return '—';
return new Date(parseInt(timestamp)).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatSize = (bytes) => {
if (!bytes) return '';
const mb = parseInt(bytes) / (1024 * 1024);
if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb.toFixed(1)} MB`;
};
const handleDelete = async (recordID) => {
if (!confirm(t('recordings.deleteConfirm'))) return;
setLoading(prev => ({ ...prev, [recordID]: 'deleting' }));
try {
await api.delete(`/recordings/${recordID}`);
toast.success(t('recordings.deleted'));
onRefresh?.();
} catch (err) {
toast.error(t('recordings.deleteFailed'));
} finally {
setLoading(prev => ({ ...prev, [recordID]: null }));
}
};
const handlePublish = async (recordID, publish) => {
setLoading(prev => ({ ...prev, [recordID]: 'publishing' }));
try {
await api.put(`/recordings/${recordID}/publish`, { publish });
toast.success(publish ? t('recordings.publishSuccess') : t('recordings.unpublishSuccess'));
onRefresh?.();
} catch (err) {
toast.error(t('recordings.publishFailed'));
} finally {
setLoading(prev => ({ ...prev, [recordID]: null }));
}
};
if (!recordings || recordings.length === 0) {
return (
<div className="text-center py-12">
<FileVideo size={48} className="mx-auto text-th-text-s/40 mb-3" />
<p className="text-th-text-s text-sm">{t('recordings.noRecordings')}</p>
</div>
);
}
return (
<div className="space-y-3">
{recordings.map(rec => (
<div key={rec.recordID} className="card p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-th-text truncate">
{rec.name}
</h4>
<span
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
rec.published
? 'bg-th-success/15 text-th-success'
: 'bg-th-warning/15 text-th-warning'
}`}
>
{rec.published ? t('recordings.published') : t('recordings.unpublished')}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-th-text-s">
<span className="flex items-center gap-1">
<Clock size={12} />
{formatDate(rec.startTime)}
</span>
<span className="flex items-center gap-1">
<Play size={12} />
{formatDuration(rec.startTime, rec.endTime)}
</span>
{rec.participants && (
<span className="flex items-center gap-1">
<Users size={12} />
{rec.participants}
</span>
)}
{rec.size && (
<span>{formatSize(rec.size)}</span>
)}
</div>
{/* Playback formats */}
{rec.formats && rec.formats.length > 0 && (
<div className="flex items-center gap-2 mt-2">
{rec.formats.map((format, idx) => (
<a
key={idx}
href={format.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors"
>
<Play size={12} />
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
</a>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handlePublish(rec.recordID, !rec.published)}
disabled={loading[rec.recordID] === 'publishing'}
className="btn-ghost text-xs py-1.5 px-2"
title={rec.published ? t('recordings.hide') : t('recordings.publish')}
>
{rec.published ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => handleDelete(rec.recordID)}
disabled={loading[rec.recordID] === 'deleting'}
className="btn-ghost text-xs py-1.5 px-2 text-th-error"
title={t('common.delete')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
</div>
);
}

108
src/components/RoomCard.jsx Normal file
View File

@@ -0,0 +1,108 @@
import { Users, Play, Trash2, Radio, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect } from 'react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
export default function RoomCard({ room, onDelete }) {
const navigate = useNavigate();
const { t } = useLanguage();
const [status, setStatus] = useState({ running: false, participantCount: 0 });
const [starting, setStarting] = useState(false);
useEffect(() => {
const checkStatus = async () => {
try {
const res = await api.get(`/rooms/${room.uid}/status`);
setStatus(res.data);
} catch {
// Ignore errors
}
};
checkStatus();
const interval = setInterval(checkStatus, 15000);
return () => clearInterval(interval);
}, [room.uid]);
return (
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/rooms/${room.uid}`)}>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
{room.name}
</h3>
{status.running && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-th-success/15 text-th-success rounded-full text-xs font-medium">
<Radio size={10} className="animate-pulse" />
{t('common.live')}
</span>
)}
</div>
<p className="text-sm text-th-text-s mt-0.5">
{room.uid.substring(0, 8)}...
</p>
</div>
</div>
{/* Room info */}
<div className="flex items-center gap-4 mb-4 text-xs 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.max_participants > 0 && (
<span>Max: {room.max_participants}</span>
)}
{room.access_code && (
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded text-[10px] font-medium">
{t('common.protected')}
</span>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
<button
onClick={async (e) => {
e.stopPropagation();
setStarting(true);
try {
if (status.running) {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
const res = await api.post(`/rooms/${room.uid}/join`, data);
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
} else {
const res = await api.post(`/rooms/${room.uid}/start`);
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
toast.success(t('room.meetingStarted'));
setTimeout(() => {
api.get(`/rooms/${room.uid}/status`).then(r => setStatus(r.data)).catch(() => {});
}, 2000);
}
} catch (err) {
toast.error(err.response?.data?.error || t('room.meetingStartFailed'));
} finally {
setStarting(false);
}
}}
disabled={starting}
className="btn-primary text-xs py-1.5 px-3 flex-1"
>
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{status.running ? t('room.join') : t('room.startMeeting')}
</button>
{onDelete && (
<button
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
title={t('common.delete')}
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
);
}

108
src/components/Sidebar.jsx Normal file
View File

@@ -0,0 +1,108 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import ThemeSelector from './ThemeSelector';
import { useState } from 'react';
export default function Sidebar({ open, onClose }) {
const { user } = useAuth();
const { t } = useLanguage();
const [themeOpen, setThemeOpen] = useState(false);
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
{ to: '/settings', icon: Settings, label: t('nav.settings') },
];
if (user?.role === 'admin') {
navItems.push({ to: '/admin', icon: Shield, label: t('nav.admin') });
}
const linkClasses = ({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-th-accent text-th-accent-t shadow-sm'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
}`;
return (
<>
<aside
className={`fixed top-0 left-0 z-40 h-full w-64 bg-th-side border-r border-th-border
transition-transform duration-300 ease-in-out
${open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0`}
>
<div className="flex flex-col h-full">
{/* Logo */}
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<Video size={18} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold gradient-text">Redlight</h1>
</div>
</div>
<button
onClick={onClose}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<X size={18} />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
{t('nav.navigation')}
</p>
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={linkClasses}
onClick={onClose}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
<div className="pt-4">
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
{t('nav.appearance')}
</p>
<button
onClick={() => setThemeOpen(!themeOpen)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-th-text-s hover:text-th-text hover:bg-th-hover transition-all duration-200"
>
<Palette size={18} />
{t('nav.changeTheme')}
</button>
</div>
</nav>
{/* User info */}
<div className="p-4 border-t border-th-border">
<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"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.name?.[0]?.toUpperCase() || '?'}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-th-text truncate">{user?.name}</p>
<p className="text-xs text-th-text-s truncate">{user?.email}</p>
</div>
</div>
</div>
</div>
</aside>
{/* Theme Selector Modal */}
{themeOpen && <ThemeSelector onClose={() => setThemeOpen(false)} />}
</>
);
}

View File

@@ -0,0 +1,99 @@
import { X, Check, Sun, Moon } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { useLanguage } from '../contexts/LanguageContext';
import { getThemeGroups } from '../themes';
export default function ThemeSelector({ onClose }) {
const { theme, setTheme } = useTheme();
const { t } = useLanguage();
const groups = getThemeGroups();
return (
<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={onClose} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
<div>
<h2 className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2>
<p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<X size={20} />
</button>
</div>
{/* Theme Grid */}
<div className="p-6 overflow-y-auto max-h-[60vh] space-y-6">
{Object.entries(groups).map(([groupName, groupThemes]) => (
<div key={groupName}>
<h3 className="text-sm font-semibold text-th-text-s uppercase tracking-wider mb-3">
{groupName}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{groupThemes.map(t => (
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={`relative group rounded-xl p-3 border-2 transition-all duration-200 text-left ${
theme === t.id
? 'border-th-accent shadow-lg scale-[1.02]'
: 'border-transparent hover:border-th-border hover:shadow-md'
}`}
style={{ backgroundColor: t.colors.bg }}
>
{/* Color preview */}
<div className="flex items-center gap-2 mb-2">
<div
className="w-full h-8 rounded-lg flex items-center px-3 gap-2"
style={{ backgroundColor: t.colors.bg }}
>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: t.colors.accent }}
/>
<div
className="h-1.5 rounded-full flex-1"
style={{ backgroundColor: t.colors.text, opacity: 0.3 }}
/>
</div>
</div>
{/* Theme info */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{t.type === 'light' ? (
<Sun size={12} style={{ color: t.colors.text }} />
) : (
<Moon size={12} style={{ color: t.colors.text }} />
)}
<span
className="text-xs font-medium"
style={{ color: t.colors.text }}
>
{t.name}
</span>
</div>
{theme === t.id && (
<div
className="w-5 h-5 rounded-full flex items-center justify-center"
style={{ backgroundColor: t.colors.accent }}
>
<Check size={12} className="text-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}