feat: initial owlcub-meet LiveKit frontend

This commit is contained in:
Romain bogdanovic 2026-03-27 17:19:55 +01:00
commit e98985d760
13 changed files with 539 additions and 0 deletions

4
.env.example Normal file
View File

@ -0,0 +1,4 @@
# LiveKit server (wss:// pour la prod)
LIVEKIT_URL=wss://livekit.cupadev.com
LIVEKIT_API_KEY=owlcub
LIVEKIT_API_SECRET=7e58d884465fe162c4f6b71846a75a797e071c9c53eb20eaf664a2141760ffed

6
cupadev.json Normal file
View File

@ -0,0 +1,6 @@
{
"framework": "nextjs",
"buildCommand": "npm install && npm run build",
"startCommand": "npm start",
"port": 3000
}

28
next.config.ts Normal file
View File

@ -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;

30
package.json Normal file
View File

@ -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"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -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,
});
}

21
src/app/globals.css Normal file
View File

@ -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;
}

16
src/app/layout.tsx Normal file
View File

@ -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>
);
}

179
src/app/page.tsx Normal file
View File

@ -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>
);
}

View File

@ -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('/')}
/>
);
}

View File

@ -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>
);
}

27
tailwind.config.ts Normal file
View File

@ -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;

21
tsconfig.json Normal file
View File

@ -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"]
}