import React,{useCallback,useEffect,useMemo,useRef,useState}from'react'; import{createRoot}from'react-dom/client'; import{Room,RoomEvent,Track}from'livekit-client'; import QRCode from'qrcode'; import'./styles.css'; const ICONS = { attachment: '/icons/attachment.png', cameraOff: '/icons/camera_off.png', cameraOn: '/icons/camera_on.png', micOff: '/icons/micro_off.png', micOn: '/icons/micro_on.png' }; function apiUrl(path) { return path; } async function readJson(response) { const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.detail || 'Ошибка запроса'); return data; } function routeFromLocation() { const path = window.location.pathname; if (path === '/') return { name: 'home' }; if (path === '/call/overloaded') return { name: 'error', code: '503' }; const match = path.match(/^\/call\/([^/]+)$/); if (match) return { name: 'call', roomName: decodeURIComponent(match[1]) }; return { name: 'error', code: '404' }; } function getInviteToken() { return new URL(window.location.href).searchParams.get('invite') || ''; } function randomGuestName() { const a = Date.now().toString(36).slice(-4); const b = Math.random().toString(36).slice(2, 6); return `Гость-${a}${b}`.slice(0, 24); } function instaRoomTitle() { return `Инста-${Math.random().toString(36).slice(2, 8)}`; } function Toasts({ items }) { return (
{items.map((toast) => (
{toast.message}
))}
); } function useToasts() { const [items, setItems] = useState([]); const showToast = useCallback((message, type = 'info') => { const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()); setItems((current) => current.concat({ id, message, type })); window.setTimeout(() => { setItems((current) => current.filter((item) => item.id !== id)); }, 2600); }, []); return [items, showToast]; } function ErrorPage({ code }) { const copy = { '403': ['Приглашение недействительно', 'Ссылка приглашения недействительна или была заменена. Запросите новую ссылку у организатора.'], '410': ['Звонок завершён', 'Эта встреча уже закончена. Попросите организатора прислать новую ссылку.'], '503': ['Сервис временно недоступен', 'Сервер перегружен. Подождите немного и обновите страницу или зайдите позже.'], '404': ['Комната не найдена', 'Такой страницы нет или ссылка устарела. Проверьте адрес или создайте новый звонок на главной странице.'] }; const [title, message] = copy[code] || copy['404']; useEffect(() => { document.title = `${title} · Linkra`; }, [title]); return (

{code}

{title}

{message}

На главную {code === '503' ? : null}
); } function HomePage({ showToast }) { const [roomTitle, setRoomTitle] = useState(''); const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [instaLoading, setInstaLoading] = useState(false); const [result, setResult] = useState(null); const qrRef = useRef(null); useEffect(() => { document.title = 'Созвон по ссылке · Linkra'; }, []); useEffect(() => { if (!result || !qrRef.current) return; const target = result.short_invite_link || result.invite_link; QRCode.toCanvas(qrRef.current, target, { width: 200, margin: 1 }).catch(() => {}); }, [result]); const createCall = async ({ quickJoin = false } = {}) => { const title = quickJoin ? instaRoomTitle() : roomTitle.trim(); const roomPassword = quickJoin ? '' : password.trim(); if (!quickJoin && title.length < 4) { showToast('Название комнаты должно содержать минимум 4 символа', 'error'); return null; } if (!quickJoin && roomPassword.length > 0 && roomPassword.length < 4) { showToast('Пароль должен содержать минимум 4 символа', 'error'); return null; } const setBusy = quickJoin ? setInstaLoading : setLoading; setBusy(true); try { const response = await fetch(apiUrl('/api/calls'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room_title: title, password: roomPassword, quick_join: quickJoin }) }); return await readJson(response); } catch (error) { showToast(error.message || 'Не удалось создать звонок', 'error'); return null; } finally { setBusy(false); } }; const handleCreate = async () => { const data = await createCall(); if (!data) return; setResult(data); showToast('Ссылка на звонок готова', 'success'); }; const handleInsta = async () => { const data = await createCall({ quickJoin: true }); if (!data) return; const target = data.short_invite_link || data.invite_link; sessionStorage.setItem(`linkra-insta:${data.room_name}`, JSON.stringify({ invite_token: data.invite_token, display_name: randomGuestName(), mic_enabled: false, camera_enabled: false })); await navigator.clipboard.writeText(target).catch(() => {}); window.location.href = target; }; const copyText = async (value) => { await navigator.clipboard.writeText(value); showToast('Ссылка скопирована', 'success'); }; return (

Созвон по ссылке

Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.

  • Камера и микрофон без установки
  • Чат и файлы в комнате
  • Короткая ссылка и QR для телефона
  • Кнопка «Инста» для быстрого входа

Новая комната

{!result ? (
) : (
Показать полную ссылку и QR-код

Название комнаты: {result.room_title}

Перейти в звонок
)}
); } function MediaTrack({ track, muted = false, className = '' }) { const ref = useRef(null); useEffect(() => { if (!track || !ref.current) return undefined; track.attach(ref.current); return () => { track.detach(ref.current); }; }, [track]); return