commit e98985d760e4ad89dc1825446913b2c516290f93 Author: Romain bogdanovic Date: Fri Mar 27 17:19:55 2026 +0100 feat: initial owlcub-meet LiveKit frontend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ab8d744 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# LiveKit server (wss:// pour la prod) +LIVEKIT_URL=wss://livekit.cupadev.com +LIVEKIT_API_KEY=owlcub +LIVEKIT_API_SECRET=7e58d884465fe162c4f6b71846a75a797e071c9c53eb20eaf664a2141760ffed diff --git a/cupadev.json b/cupadev.json new file mode 100644 index 0000000..530828d --- /dev/null +++ b/cupadev.json @@ -0,0 +1,6 @@ +{ + "framework": "nextjs", + "buildCommand": "npm install && npm run build", + "startCommand": "npm start", + "port": 3000 +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..073eb8b --- /dev/null +++ b/next.config.ts @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e5059c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/app/api/token/route.ts b/src/app/api/token/route.ts new file mode 100644 index 0000000..99f669f --- /dev/null +++ b/src/app/api/token/route.ts @@ -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, + }); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..e849c0b --- /dev/null +++ b/src/app/globals.css @@ -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; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..91bcff2 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..5b747c9 --- /dev/null +++ b/src/app/page.tsx @@ -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 ( +
+ {/* Logo */} +
+
+
+ + + +
+ OwlCub Meet +
+

Visioconférence sécurisée

+
+ + {/* Card */} +
+ + {/* Tabs */} +
+ + +
+ + {/* Room code display (create mode) */} + {mode === 'create' && ( +
+ +
+
+ {generatedRoom} +
+ +
+

+ Partagez ce code ou{' '} + {' '} + à votre interlocuteur. +

+
+ )} + + {/* Room code input (join mode) */} + {mode === 'join' && ( +
+ + { 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()} + /> +
+ )} + + {/* Display name */} +
+ + { 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 + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +

+ Visioconférence chiffrée · Propulsé par LiveKit +

+
+ ); +} diff --git a/src/app/room/[name]/page.tsx b/src/app/room/[name]/page.tsx new file mode 100644 index 0000000..5e2668c --- /dev/null +++ b/src/app/room/[name]/page.tsx @@ -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 ( +
+
+

Rejoindre la réunion

+

+ Code :{' '} + {roomName} +

+ + 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); + }} + /> + +
+
+ ); + } + + const finalName = displayName || inputName; + + return ( + router.push('/')} + /> + ); +} diff --git a/src/components/MeetingRoom.tsx b/src/components/MeetingRoom.tsx new file mode 100644 index 0000000..fdd8289 --- /dev/null +++ b/src/components/MeetingRoom.tsx @@ -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 = { + [ConnectionState.Connecting]: 'Connexion en cours…', + [ConnectionState.Reconnecting]: 'Reconnexion…', + [ConnectionState.Disconnected]: 'Déconnecté', + }; + + return ( +
+ + {messages[state] ?? state} +
+ ); +} + +export function MeetingRoom({ roomName, displayName, onLeave }: Props) { + const [token, setToken] = useState(null); + const [serverUrl, setServerUrl] = useState(null); + const [error, setError] = useState(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 ( +
+

Impossible de rejoindre la réunion : {error}

+ +
+ ); + } + + if (!token || !serverUrl) { + return ( +
+
+ + Connexion à la réunion… +
+
+ ); + } + + return ( + + + + + + ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..5f4cf0c --- /dev/null +++ b/tailwind.config.ts @@ -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; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fba2bf3 --- /dev/null +++ b/tsconfig.json @@ -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"] +}