feat: initial owlcub-meet LiveKit frontend
This commit is contained in:
commit
e98985d760
|
|
@ -0,0 +1,4 @@
|
||||||
|
# LiveKit server (wss:// pour la prod)
|
||||||
|
LIVEKIT_URL=wss://livekit.cupadev.com
|
||||||
|
LIVEKIT_API_KEY=owlcub
|
||||||
|
LIVEKIT_API_SECRET=7e58d884465fe162c4f6b71846a75a797e071c9c53eb20eaf664a2141760ffed
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"framework": "nextjs",
|
||||||
|
"buildCommand": "npm install && npm run build",
|
||||||
|
"startCommand": "npm start",
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: [
|
||||||
|
"default-src 'self'",
|
||||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"img-src 'self' data: blob:",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"connect-src 'self' https: wss:",
|
||||||
|
"worker-src 'self' blob:",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
].join('; '),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "owlcub-meet",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/components-react": "^2.9.0",
|
||||||
|
"@livekit/components-styles": "^1.1.0",
|
||||||
|
"livekit-client": "^2.9.0",
|
||||||
|
"livekit-server-sdk": "^2.10.0",
|
||||||
|
"next": "15.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"clsx": "^2.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"typescript": "^5",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { AccessToken } from 'livekit-server-sdk';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const room = searchParams.get('room');
|
||||||
|
const username = searchParams.get('username');
|
||||||
|
|
||||||
|
if (!room || !username) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'room and username are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.LIVEKIT_API_KEY;
|
||||||
|
const apiSecret = process.env.LIVEKIT_API_SECRET;
|
||||||
|
const serverUrl = process.env.LIVEKIT_URL;
|
||||||
|
|
||||||
|
if (!apiKey || !apiSecret || !serverUrl) {
|
||||||
|
console.error('Missing LiveKit env vars: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL');
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Server configuration error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = new AccessToken(apiKey, apiSecret, {
|
||||||
|
identity: `${username}-${Date.now()}`,
|
||||||
|
name: username,
|
||||||
|
ttl: '4h',
|
||||||
|
});
|
||||||
|
|
||||||
|
token.addGrant({
|
||||||
|
roomJoin: true,
|
||||||
|
room,
|
||||||
|
canPublish: true,
|
||||||
|
canSubscribe: true,
|
||||||
|
canPublishData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
token: await token.toJwt(),
|
||||||
|
serverUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import '@livekit/components-styles';
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--lk-theme-color: #3b5ff8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0f1117;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'OwlCub Meet',
|
||||||
|
description: 'Visioconférence sécurisée OwlCub',
|
||||||
|
icons: { icon: '/favicon.ico' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="fr">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
function generateRoomCode(): string {
|
||||||
|
const adjectives = ['swift', 'bright', 'calm', 'bold', 'clear', 'deep', 'keen', 'sharp'];
|
||||||
|
const nouns = ['falcon', 'summit', 'bridge', 'stream', 'nexus', 'forge', 'pulse', 'orbit'];
|
||||||
|
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||||
|
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||||
|
const num = Math.floor(1000 + Math.random() * 9000);
|
||||||
|
return `${adj}-${noun}-${num}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [roomCode, setRoomCode] = useState('');
|
||||||
|
const [mode, setMode] = useState<'create' | 'join'>('create');
|
||||||
|
const [generatedRoom] = useState(() => generateRoomCode());
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const roomToJoin = mode === 'create' ? generatedRoom : roomCode;
|
||||||
|
|
||||||
|
const handleStart = useCallback(() => {
|
||||||
|
if (!displayName.trim()) {
|
||||||
|
setError('Veuillez entrer votre nom');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'join' && !roomCode.trim()) {
|
||||||
|
setError('Veuillez entrer le code de la réunion');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const room = encodeURIComponent(roomToJoin.trim());
|
||||||
|
const name = encodeURIComponent(displayName.trim());
|
||||||
|
router.push(`/room/${room}?name=${name}`);
|
||||||
|
}, [displayName, roomCode, mode, roomToJoin, router]);
|
||||||
|
|
||||||
|
const copyLink = useCallback(async () => {
|
||||||
|
const url = `${window.location.origin}/room/${encodeURIComponent(generatedRoom)}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, [generatedRoom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-[#0f1117] via-[#131829] to-[#0f1117]">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-10 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-brand-500 flex items-center justify-center">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className="text-white">
|
||||||
|
<path d="M15 10l4.553-2.277A1 1 0 0121 8.723v6.554a1 1 0 01-1.447.894L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-white tracking-tight">OwlCub Meet</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-sm">Visioconférence sécurisée</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="w-full max-w-md bg-[#1a1f2e] border border-white/8 rounded-2xl p-6 shadow-2xl">
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 bg-[#0f1117] rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('create'); setError(''); }}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 py-2 px-4 rounded-md text-sm font-medium transition-all',
|
||||||
|
mode === 'create'
|
||||||
|
? 'bg-brand-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Créer une réunion
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('join'); setError(''); }}
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 py-2 px-4 rounded-md text-sm font-medium transition-all',
|
||||||
|
mode === 'join'
|
||||||
|
? 'bg-brand-600 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Rejoindre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Room code display (create mode) */}
|
||||||
|
{mode === 'create' && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs text-slate-400 mb-2 font-medium uppercase tracking-wider">
|
||||||
|
Code de la réunion
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 bg-[#0f1117] border border-white/10 rounded-lg px-4 py-3 font-mono text-brand-300 text-sm tracking-widest select-all">
|
||||||
|
{generatedRoom}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={copyLink}
|
||||||
|
className="px-4 py-3 bg-[#0f1117] border border-white/10 rounded-lg text-slate-300 hover:text-white hover:border-white/20 transition-all text-sm whitespace-nowrap"
|
||||||
|
title="Copier le lien"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<span className="text-green-400">✓ Copié</span>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Partagez ce code ou{' '}
|
||||||
|
<button onClick={copyLink} className="text-brand-400 hover:text-brand-300 underline-offset-2 hover:underline">
|
||||||
|
copiez le lien direct
|
||||||
|
</button>{' '}
|
||||||
|
à votre interlocuteur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Room code input (join mode) */}
|
||||||
|
{mode === 'join' && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<label className="block text-xs text-slate-400 mb-2 font-medium uppercase tracking-wider">
|
||||||
|
Code de la réunion
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={roomCode}
|
||||||
|
onChange={e => { setRoomCode(e.target.value); setError(''); }}
|
||||||
|
placeholder="ex: swift-falcon-4821"
|
||||||
|
className="w-full bg-[#0f1117] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-brand-500 transition-colors text-sm"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display name */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-xs text-slate-400 mb-2 font-medium uppercase tracking-wider">
|
||||||
|
Votre nom
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={e => { setDisplayName(e.target.value); setError(''); }}
|
||||||
|
placeholder="Jean Dupont"
|
||||||
|
className="w-full bg-[#0f1117] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-brand-500 transition-colors text-sm"
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleStart()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleStart}
|
||||||
|
className="w-full py-3 bg-brand-600 hover:bg-brand-500 text-white font-semibold rounded-lg transition-all shadow-lg shadow-brand-900/40 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{mode === 'create' ? 'Démarrer la réunion' : 'Rejoindre'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-xs text-slate-600">
|
||||||
|
Visioconférence chiffrée · Propulsé par LiveKit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useParams, useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MeetingRoom } from '@/components/MeetingRoom';
|
||||||
|
|
||||||
|
export default function RoomPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const roomName = decodeURIComponent(params.name as string);
|
||||||
|
const displayName = searchParams.get('name') ?? '';
|
||||||
|
|
||||||
|
const [askName, setAskName] = useState(!displayName);
|
||||||
|
const [inputName, setInputName] = useState('');
|
||||||
|
|
||||||
|
// Si le user arrive directement via un lien partagé sans ?name=
|
||||||
|
if (askName) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-[#0f1117] via-[#131829] to-[#0f1117]">
|
||||||
|
<div className="w-full max-w-sm bg-[#1a1f2e] border border-white/8 rounded-2xl p-6 shadow-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-1">Rejoindre la réunion</h2>
|
||||||
|
<p className="text-slate-400 text-sm mb-5">
|
||||||
|
Code :{' '}
|
||||||
|
<span className="text-brand-300 font-mono text-xs">{roomName}</span>
|
||||||
|
</p>
|
||||||
|
<label className="block text-xs text-slate-400 mb-2 font-medium uppercase tracking-wider">
|
||||||
|
Votre nom
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputName}
|
||||||
|
onChange={e => setInputName(e.target.value)}
|
||||||
|
placeholder="Jean Dupont"
|
||||||
|
autoFocus
|
||||||
|
className="w-full mb-5 bg-[#0f1117] border border-white/10 rounded-lg px-4 py-3 text-white placeholder-slate-600 focus:outline-none focus:border-brand-500 transition-colors text-sm"
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && inputName.trim()) setAskName(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (inputName.trim()) setAskName(false); }}
|
||||||
|
className="w-full py-3 bg-brand-600 hover:bg-brand-500 text-white font-semibold rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Rejoindre
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalName = displayName || inputName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MeetingRoom
|
||||||
|
roomName={roomName}
|
||||||
|
displayName={finalName}
|
||||||
|
onLeave={() => router.push('/')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import '@livekit/components-styles';
|
||||||
|
import {
|
||||||
|
LiveKitRoom,
|
||||||
|
VideoConference,
|
||||||
|
RoomAudioRenderer,
|
||||||
|
useConnectionState,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { ConnectionState } from 'livekit-client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
roomName: string;
|
||||||
|
displayName: string;
|
||||||
|
onLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionStatus() {
|
||||||
|
const state = useConnectionState();
|
||||||
|
if (state === ConnectionState.Connected) return null;
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
[ConnectionState.Connecting]: 'Connexion en cours…',
|
||||||
|
[ConnectionState.Reconnecting]: 'Reconnexion…',
|
||||||
|
[ConnectionState.Disconnected]: 'Déconnecté',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 left-1/2 -translate-x-1/2 z-50 bg-[#1a1f2e] border border-white/10 rounded-full px-4 py-2 text-sm text-slate-300 shadow-xl flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
{messages[state] ?? state}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeetingRoom({ roomName, displayName, onLeave }: Props) {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [serverUrl, setServerUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams({ room: roomName, username: displayName });
|
||||||
|
fetch(`/api/token?${params}`)
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(d => {
|
||||||
|
setToken(d.token);
|
||||||
|
setServerUrl(d.serverUrl);
|
||||||
|
})
|
||||||
|
.catch(e => setError(e.message));
|
||||||
|
}, [roomName, displayName]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center gap-4 bg-[#0f1117] text-white">
|
||||||
|
<p className="text-red-400 text-sm">Impossible de rejoindre la réunion : {error}</p>
|
||||||
|
<button onClick={onLeave} className="text-sm text-slate-400 hover:text-white underline">
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !serverUrl) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0f1117]">
|
||||||
|
<div className="flex items-center gap-3 text-slate-400">
|
||||||
|
<span className="w-5 h-5 border-2 border-brand-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-sm">Connexion à la réunion…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LiveKitRoom
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
token={token}
|
||||||
|
connect={true}
|
||||||
|
video={true}
|
||||||
|
audio={true}
|
||||||
|
onDisconnected={onLeave}
|
||||||
|
style={{ height: '100dvh', width: '100vw' }}
|
||||||
|
>
|
||||||
|
<ConnectionStatus />
|
||||||
|
<VideoConference />
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
</LiveKitRoom>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#f0f4ff',
|
||||||
|
100: '#dde8ff',
|
||||||
|
200: '#c3d4fe',
|
||||||
|
300: '#9ab7fd',
|
||||||
|
400: '#6b8dfb',
|
||||||
|
500: '#3b5ff8',
|
||||||
|
600: '#2640ed',
|
||||||
|
700: '#1e2fd9',
|
||||||
|
800: '#1f29b0',
|
||||||
|
900: '#1f278b',
|
||||||
|
950: '#161a55',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue