init commit

This commit is contained in:
2026-05-10 08:41:16 +03:00
commit c1ef5871c2
32 changed files with 3662 additions and 0 deletions

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
npm-debug.log

16
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang='ru'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />
<meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' />
<title>Linkra</title>
</head>
<body>
<div id='root'></div>
<script type='module' src='/src/main.jsx'></script>
</body>
</html>

37
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 120m;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /health {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /i/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

1336
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
frontend/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@vitejs/plugin-react": "latest",
"vite": "latest",
"typescript": "latest",
"react": "latest",
"react-dom": "latest",
"livekit-client": "latest",
"qrcode": "latest"
},
"devDependencies": {}
}

730
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,730 @@
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 TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert';
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 (
<div className='toast-stack'>
{items.map((toast) => (
<div className={`toast ${toast.type}`} key={toast.id}>{toast.message}</div>
))}
</div>
);
}
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 (
<main className='container error-page'>
<section className='card error-card'>
<p className='error-code'>{code}</p>
<h1>{title}</h1>
<p className='muted'>{message}</p>
<div className='row centered'>
<a className='btn primary' href='/'>На главную</a>
{code === '503' ? <button className='btn secondary' type='button' onClick={() => window.location.reload()}>Обновить страницу</button> : null}
</div>
</section>
</main>
);
}
function HomePage({ showToast }) {
const [config, setConfig] = useState({ telegram_alerting_available: false, max_attachment_size_mb: 100 });
const [settingsOpen, setSettingsOpen] = useState(false);
const [telegramAlert, setTelegramAlert] = useState(() => localStorage.getItem(TELEGRAM_PREF_KEY) === '1');
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';
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
}, []);
useEffect(() => {
localStorage.setItem(TELEGRAM_PREF_KEY, telegramAlert ? '1' : '0');
}, [telegramAlert]);
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,
telegram_alert_enabled: config.telegram_alerting_available && telegramAlert,
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 (
<main className='container home-shell'>
<div className='home-grid'>
<header className='home-hero'>
<h1>Созвон по ссылке</h1>
<p className='muted home-hero-lead'>Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.</p>
<ul className='home-features'>
<li>Камера и микрофон без установки</li>
<li>Чат и файлы в комнате</li>
<li>Короткая ссылка и QR для телефона</li>
<li>Кнопка «Инста» для быстрого входа</li>
</ul>
</header>
<section className='card home-card'>
<div className='home-card-header'>
<h2>Новая комната</h2>
<div className='settings-wrap'>
<button className='icon-btn small' type='button' onClick={() => setSettingsOpen((value) => !value)}></button>
{settingsOpen ? (
<div className='settings-menu'>
{config.telegram_alerting_available ? (
<label className='checkbox-line'>
<input type='checkbox' checked={telegramAlert} onChange={(event) => setTelegramAlert(event.target.checked)} />
<span>Итоги созвона в Telegram</span>
</label>
) : <p className='muted'>Итоги в Telegram недоступны на сервере</p>}
</div>
) : null}
</div>
</div>
{!result ? (
<div className='stack'>
<label className='field'>
<span>Название комнаты</span>
<input value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} type='text' placeholder='Например, Планёрка команды' maxLength='100' />
</label>
<label className='field'>
<span>Пароль комнаты <small className='muted'>(необязательно)</small></span>
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Например, team2026' maxLength='100' />
</label>
<div className='home-actions'>
<button className='btn primary' type='button' disabled={loading} onClick={handleCreate}>{loading ? 'Создаю...' : 'Создать звонок'}</button>
<button className='btn secondary' type='button' disabled={instaLoading} onClick={handleInsta}>{instaLoading ? 'Создаём...' : 'Инста'}</button>
</div>
</div>
) : (
<div className='stack'>
<label className='field'>
<span>Ссылка приглашения</span>
<input value={result.short_invite_link || result.invite_link} readOnly onFocus={(event) => event.target.select()} />
</label>
<button className='btn primary' type='button' onClick={() => copyText(result.short_invite_link || result.invite_link)}>Копировать</button>
<details>
<summary>Показать полную ссылку и QR-код</summary>
<label className='field'>
<span>Полная ссылка</span>
<input value={result.invite_link} readOnly onFocus={(event) => event.target.select()} />
</label>
<button className='btn secondary' type='button' onClick={() => copyText(result.invite_link)}>Копировать полную</button>
<div className='qr-box'><canvas ref={qrRef}></canvas></div>
</details>
<p className='muted'>Название комнаты: {result.room_title}</p>
<a className='btn primary link-btn' href={result.short_invite_link || result.invite_link}>Перейти в звонок</a>
</div>
)}
</section>
</div>
</main>
);
}
function MediaTrack({ track, kind, muted = false, className = '' }) {
const ref = useRef(null);
useEffect(() => {
if (!track || !ref.current) return undefined;
track.attach(ref.current);
return () => {
track.detach(ref.current);
};
}, [track]);
if (kind === 'audio') return <audio ref={ref} autoPlay playsInline muted={muted} className={className} />;
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
}
function VideoTile({ item, active }) {
const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare);
const screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare);
const audio = item.tracks.filter((entry) => entry.kind === 'audio');
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
return (
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
<div className='video-label-row'>
<span>{item.name}</span>
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
</div>
{screen ? (
<div className='screen-slot'>
<MediaTrack track={screen.track} kind='video' className='screen-share-video' muted={item.isLocal} />
</div>
) : null}
<div className='media-slot'>
{camera ? <MediaTrack track={camera.track} kind='video' className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>}
{audio.map((entry) => item.isLocal ? null : <MediaTrack key={entry.id} track={entry.track} kind='audio' className='hidden-audio' />)}
</div>
</div>
);
}
function CallPage({ roomName, showToast }) {
const [call, setCall] = useState(null);
const [loadError, setLoadError] = useState(null);
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [micEnabled, setMicEnabled] = useState(true);
const [cameraEnabled, setCameraEnabled] = useState(true);
const [joining, setJoining] = useState(false);
const [room, setRoom] = useState(null);
const [participants, setParticipants] = useState([]);
const [activeSpeakers, setActiveSpeakers] = useState([]);
const [messages, setMessages] = useState([]);
const [chatText, setChatText] = useState('');
const [config, setConfig] = useState({ max_attachment_size_mb: 100 });
const [elapsed, setElapsed] = useState(0);
const previewRef = useRef(null);
const previewStreamRef = useRef(null);
const fileRef = useRef(null);
const inviteToken = useMemo(getInviteToken, []);
const joined = !!room;
const refreshParticipants = useCallback((nextRoom) => {
if (!nextRoom) return;
const mapParticipant = (participant, isLocal) => {
const tracks = Array.from(participant.trackPublications.values())
.filter((publication) => publication.track)
.map((publication) => ({
id: publication.trackSid || publication.track.sid || `${participant.identity}-${publication.source}`,
track: publication.track,
source: publication.source,
kind: publication.kind || publication.track.kind
}));
return {
identity: participant.identity,
name: participant.name || participant.identity,
isLocal,
tracks
};
};
setParticipants([
mapParticipant(nextRoom.localParticipant, true),
...Array.from(nextRoom.remoteParticipants.values()).map((participant) => mapParticipant(participant, false))
]);
}, []);
useEffect(() => {
fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}`))
.then(readJson)
.then((data) => {
if (!data.is_active) {
setLoadError('410');
return;
}
setCall(data);
document.title = `${data.room_title} · Linkra`;
})
.catch(() => setLoadError('404'));
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
}, [roomName]);
useEffect(() => {
const raw = sessionStorage.getItem(`linkra-insta:${roomName}`);
if (!raw) return;
try {
const data = JSON.parse(raw);
if (data.invite_token && data.invite_token !== inviteToken) return;
setDisplayName(data.display_name || randomGuestName());
setMicEnabled(Boolean(data.mic_enabled));
setCameraEnabled(Boolean(data.camera_enabled));
} catch {
return;
}
}, [inviteToken, roomName]);
useEffect(() => {
if (joined || !navigator.mediaDevices) return undefined;
let cancelled = false;
const run = async () => {
if (previewStreamRef.current) {
previewStreamRef.current.getTracks().forEach((track) => track.stop());
previewStreamRef.current = null;
}
if (!cameraEnabled && !micEnabled) {
if (previewRef.current) previewRef.current.srcObject = null;
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: cameraEnabled });
if (cancelled) {
stream.getTracks().forEach((track) => track.stop());
return;
}
previewStreamRef.current = stream;
if (previewRef.current) previewRef.current.srcObject = cameraEnabled ? new MediaStream(stream.getVideoTracks()) : null;
} catch {
showToast('Не удалось получить доступ к камере или микрофону', 'error');
}
};
run();
return () => {
cancelled = true;
};
}, [cameraEnabled, joined, micEnabled, showToast]);
useEffect(() => {
if (!joined) return undefined;
const started = Date.now();
const interval = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - started) / 1000));
}, 1000);
return () => window.clearInterval(interval);
}, [joined]);
useEffect(() => () => {
if (previewStreamRef.current) previewStreamRef.current.getTracks().forEach((track) => track.stop());
if (room) room.disconnect();
}, [room]);
const publishData = async (payload) => {
if (!room) return;
const encoded = new TextEncoder().encode(JSON.stringify(payload));
await room.localParticipant.publishData(encoded, { reliable: true });
};
const joinCall = async () => {
const name = displayName.trim();
if (!name) {
showToast('Введи имя', 'error');
return;
}
if (!inviteToken) {
setLoadError('403');
return;
}
setJoining(true);
try {
const response = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/join`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
display_name: name,
password,
with_video: cameraEnabled,
with_audio: micEnabled
})
});
const data = await readJson(response);
if (previewStreamRef.current) {
previewStreamRef.current.getTracks().forEach((track) => track.stop());
previewStreamRef.current = null;
}
const nextRoom = new Room({ adaptiveStream: true, dynacast: true });
const refresh = () => refreshParticipants(nextRoom);
nextRoom
.on(RoomEvent.TrackSubscribed, refresh)
.on(RoomEvent.TrackUnsubscribed, refresh)
.on(RoomEvent.LocalTrackPublished, refresh)
.on(RoomEvent.LocalTrackUnpublished, refresh)
.on(RoomEvent.ParticipantConnected, refresh)
.on(RoomEvent.ParticipantDisconnected, refresh)
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => setActiveSpeakers(speakers.map((speaker) => speaker.identity)))
.on(RoomEvent.DataReceived, (payload, participant) => {
const text = new TextDecoder().decode(payload);
const message = JSON.parse(text);
if (message.type === 'chat') {
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: participant?.name || participant?.identity || 'Участник',
text: message.text,
own: false
}));
}
if (message.type === 'file') {
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: participant?.name || participant?.identity || 'Участник',
attachment: message.attachment,
own: false
}));
}
})
.on(RoomEvent.Disconnected, () => {
setRoom(null);
setParticipants([]);
});
await nextRoom.connect(data.server_url, data.participant_token);
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
setRoom(nextRoom);
refreshParticipants(nextRoom);
sessionStorage.removeItem(`linkra-insta:${roomName}`);
showToast('Ты в комнате', 'success');
} catch (error) {
showToast(error.message || 'Не удалось войти в звонок', 'error');
} finally {
setJoining(false);
}
};
const leaveCall = async () => {
if (!room) return;
await room.disconnect();
setRoom(null);
setParticipants([]);
};
const toggleMic = async () => {
if (!room) {
setMicEnabled((value) => !value);
return;
}
const next = !micEnabled;
await room.localParticipant.setMicrophoneEnabled(next);
setMicEnabled(next);
refreshParticipants(room);
};
const toggleCamera = async () => {
if (!room) {
setCameraEnabled((value) => !value);
return;
}
const next = !cameraEnabled;
await room.localParticipant.setCameraEnabled(next);
setCameraEnabled(next);
refreshParticipants(room);
};
const toggleScreen = async () => {
if (!room) return;
const enabled = Array.from(room.localParticipant.trackPublications.values()).some((publication) => publication.source === Track.Source.ScreenShare && publication.track);
await room.localParticipant.setScreenShareEnabled(!enabled);
refreshParticipants(room);
};
const finishCall = async () => {
if (!window.confirm('Завершить звонок для всех участников?')) return;
try {
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/finish`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_token: inviteToken })
}).then(readJson);
await leaveCall();
setLoadError('410');
} catch (error) {
showToast(error.message || 'Не удалось завершить звонок', 'error');
}
};
const sendChat = async () => {
const text = chatText.trim();
if (!text || !room) return;
try {
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/chat-events`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_token: inviteToken,
display_name: displayName.trim(),
participant_identity: room.localParticipant.identity,
text
})
}).catch(() => {});
await publishData({ type: 'chat', text });
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: `${displayName.trim()} (ты)`,
text,
own: true
}));
setChatText('');
} catch (error) {
showToast(error.message || 'Не удалось отправить сообщение', 'error');
}
};
const sendFile = async (file) => {
if (!file || !room) return;
if (file.size > config.max_attachment_size_mb * 1024 * 1024) {
showToast(`Максимальный размер файла — ${config.max_attachment_size_mb} МБ`, 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('display_name', displayName.trim() || 'Участник');
formData.append('participant_identity', room.localParticipant.identity);
try {
const attachment = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/attachments?invite_token=${encodeURIComponent(inviteToken)}`), {
method: 'POST',
body: formData
}).then(readJson);
await publishData({ type: 'file', attachment });
setMessages((current) => current.concat({
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
author: `${displayName.trim()} (ты)`,
attachment,
own: true
}));
showToast('Файл отправлен', 'success');
} catch (error) {
showToast(error.message || 'Не удалось загрузить файл', 'error');
} finally {
if (fileRef.current) fileRef.current.value = '';
}
};
const copyInvite = async () => {
await navigator.clipboard.writeText(window.location.href);
showToast('Ссылка скопирована', 'success');
};
const time = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`;
if (loadError) return <ErrorPage code={loadError} />;
if (!call) return <main className='container'><section className='card'>Загрузка...</section></main>;
return (
<main className='container wide'>
{!joined ? (
<section className='card join-card'>
<div className='prejoin-layout'>
<div className='preview-stage'>
{cameraEnabled ? <video ref={previewRef} className='prejoin-preview-video' autoPlay playsInline muted></video> : <div className='preview-placeholder'><div className='avatar-placeholder'>?</div><p>Камера выключена</p></div>}
</div>
<div className='stack'>
<h1>{call.room_title}</h1>
<p className='muted'>Проверь камеру и микрофон перед входом в комнату.</p>
<label className='field'>
<span>Твоё имя</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} type='text' placeholder='Например, Артём' maxLength='50' />
</label>
{call.has_password ? (
<label className='field'>
<span>Пароль комнаты</span>
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Введите пароль комнаты' maxLength='100' />
</label>
) : null}
<div className='row'>
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</button>
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</button>
</div>
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
</div>
</div>
</section>
) : (
<>
<section className='card meeting-card'>
<div className='meeting-topbar'>
<div>
<div className='meeting-title'>Созвон</div>
<h1 className='meeting-room-name'>{call.room_title}</h1>
<p className='muted'>В комнате: {participants.length}</p>
</div>
<div className='meeting-controls'>
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic}>Mic</button>
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera}>Cam</button>
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
<div className='meeting-timer'>{time}</div>
</div>
</div>
<div className='videos'>
{participants.map((item) => <VideoTile key={item.identity} item={item} active={activeSpeakers.includes(item.identity)} />)}
</div>
</section>
<aside className='card chat-card'>
<h2>Чат комнаты</h2>
<div className='chat-messages'>
{messages.map((message) => (
<div className={`chat-message${message.own ? ' own' : ''}`} key={message.id}>
<div className='chat-author'>{message.author}</div>
{message.attachment ? (
<a className='file-link' href={`${message.attachment.download_url}?invite_token=${encodeURIComponent(inviteToken)}`} target='_blank' rel='noreferrer'>{message.attachment.file_name}</a>
) : <div className='chat-text'>{message.text}</div>}
</div>
))}
</div>
<div className='chat-form'>
<input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' />
<input ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
</div>
</aside>
<div className='call-bottom-actions is-visible'>
<button className='btn secondary' type='button' onClick={leaveCall}>Выйти</button>
<button className='btn danger' type='button' onClick={finishCall}>Завершить</button>
</div>
</>
)}
</main>
);
}
function App() {
const [route, setRoute] = useState(routeFromLocation);
const [toasts, showToast] = useToasts();
useEffect(() => {
const onPop = () => setRoute(routeFromLocation());
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
return (
<>
<Toasts items={toasts} />
{route.name === 'home' ? <HomePage showToast={showToast} /> : null}
{route.name === 'call' ? <CallPage roomName={route.roomName} showToast={showToast} /> : null}
{route.name === 'error' ? <ErrorPage code={route.code} /> : null}
</>
);
}
createRoot(document.getElementById('root')).render(<App />);

535
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,535 @@
:root {
--bg: #0c1222;
--bg-deep: #060910;
--card: #111827;
--line: #243041;
--text: #f8fafc;
--muted: #94a3b8;
--primary: #2563eb;
--danger: #dc2626;
--success: #22c55e;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Inter, Arial, sans-serif;
color: var(--text);
background:
radial-gradient(ellipse 110% 75% at 50% -12%, rgba(37, 99, 235, 0.2), transparent 52%),
linear-gradient(168deg, var(--bg-deep) 0%, var(--bg) 44%, #0f172a 100%);
}
a {
color: inherit;
}
.container {
max-width: 1120px;
margin: 0 auto;
padding: 32px 16px;
}
.container.wide {
max-width: 1680px;
}
.home-shell {
min-height: 100vh;
display: flex;
align-items: center;
}
.home-grid {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 440px);
gap: 48px;
align-items: center;
}
.card {
background: rgba(17, 24, 39, 0.96);
border: 1px solid var(--line);
border-radius: 20px;
padding: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
}
h1,
h2 {
margin-top: 0;
}
.muted {
color: var(--muted);
line-height: 1.55;
}
.home-hero-lead {
max-width: 44ch;
}
.home-features {
margin: 24px 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 10px;
color: var(--muted);
}
.home-features li {
position: relative;
padding-left: 22px;
}
.home-features li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--primary);
}
.home-card-header,
.meeting-topbar,
.row {
display: flex;
gap: 12px;
align-items: center;
}
.home-card-header,
.meeting-topbar {
justify-content: space-between;
}
.centered {
justify-content: center;
}
.stack {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
input {
width: 100%;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: #0b1220;
color: white;
font: inherit;
}
.btn,
.icon-btn,
.toggle-btn {
border: 0;
border-radius: 12px;
padding: 12px 16px;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: var(--primary);
color: white;
}
.btn.secondary,
.toggle-btn {
background: #f8fafc;
color: #111827;
}
.btn.danger {
background: var(--danger);
color: white;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.link-btn {
display: inline-flex;
justify-content: center;
text-decoration: none;
}
.icon-btn {
min-width: 50px;
min-height: 50px;
background: #1f2937;
color: white;
}
.icon-btn.small {
min-width: 44px;
min-height: 44px;
padding: 0;
}
.icon-btn.is-off {
background: #3a1111;
color: #fecaca;
}
.toggle-btn.active {
background: #13274f;
color: white;
border: 1px solid #355cae;
}
.toggle-btn.inactive {
background: #1f2937;
color: #cbd5e1;
border: 1px solid #4b5563;
}
.home-actions {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
}
.settings-wrap {
position: relative;
}
.settings-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 260px;
padding: 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(17, 24, 39, 0.98);
}
.checkbox-line {
display: flex;
gap: 10px;
align-items: center;
}
.checkbox-line input {
width: 18px;
height: 18px;
}
.qr-box {
display: inline-flex;
padding: 12px;
border-radius: 14px;
background: white;
}
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 1000;
display: grid;
gap: 10px;
max-width: min(420px, calc(100vw - 32px));
}
.toast {
padding: 12px 16px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(17, 24, 39, 0.96);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.toast.success {
border-color: rgba(34, 197, 94, 0.5);
}
.toast.error {
border-color: rgba(239, 68, 68, 0.55);
}
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-card {
max-width: 500px;
text-align: center;
}
.error-code {
margin: 0 0 12px;
color: var(--muted);
letter-spacing: 0.12em;
}
.join-card {
margin-bottom: 20px;
}
.prejoin-layout {
display: grid;
grid-template-columns: minmax(320px, 1.2fr) minmax(300px, 0.9fr);
gap: 20px;
}
.preview-stage {
min-height: 420px;
border-radius: 18px;
overflow: hidden;
background: #020617;
border: 1px solid rgba(148, 163, 184, 0.16);
display: flex;
align-items: center;
justify-content: center;
}
.prejoin-preview-video {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.meeting-card {
margin-bottom: 20px;
}
.meeting-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.meeting-room-name {
margin-bottom: 0;
}
.meeting-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
}
.meeting-timer {
min-width: 74px;
text-align: center;
font-size: 18px;
font-weight: 700;
color: #93c5fd;
}
.videos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.video-tile {
background: #0b1220;
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
min-height: 360px;
}
.video-tile.speaking {
border-color: var(--success);
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.35);
}
.video-label-row {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
}
.screen-slot {
height: 260px;
background: #020617;
}
.media-slot {
min-height: 320px;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #020617;
}
.media-slot video,
.screen-slot video {
width: 100%;
height: 100%;
min-height: inherit;
object-fit: cover;
}
.screen-share-video {
object-fit: contain !important;
}
.local-video {
transform: scaleX(-1);
}
.avatar-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #1d4ed8, #3b82f6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 44px;
font-weight: 800;
}
.hidden-audio {
width: 0;
height: 0;
opacity: 0;
}
.chat-card {
margin-bottom: 96px;
}
.chat-messages {
min-height: 260px;
max-height: 420px;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 16px;
background: #0b1220;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-message {
background: #111827;
border: 1px solid #223046;
border-radius: 12px;
padding: 10px 12px;
}
.chat-message.own {
background: #102143;
border-color: #274b8f;
}
.chat-author {
font-size: 12px;
color: #93c5fd;
margin-bottom: 6px;
font-weight: 700;
}
.chat-text {
word-break: break-word;
}
.chat-form {
margin-top: 14px;
display: grid;
gap: 10px;
}
.file-link {
color: #93c5fd;
}
.call-bottom-actions {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 50;
display: flex;
gap: 12px;
padding: 14px 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(11, 18, 32, 0.96);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22);
}
@media (max-width: 900px) {
.home-grid,
.prejoin-layout {
grid-template-columns: 1fr;
}
.meeting-topbar {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 640px) {
.container {
padding: 20px 12px;
}
.home-actions,
.row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.card {
padding: 18px 14px;
}
.preview-stage {
min-height: 280px;
}
.call-bottom-actions {
width: calc(100% - 24px);
flex-direction: column;
}
}

15
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import{defineConfig}from'vite';
import react from'@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/health': 'http://localhost:8000',
'/i': 'http://localhost:8000'
}
}
});