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