init commit
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
APP_BASE_URL=https://linkra.ru
|
||||||
|
|
||||||
|
LIVEKIT_URL=ws://livekit:7880
|
||||||
|
LIVEKIT_PUBLIC_URL=wss://rtc.linkra.ru
|
||||||
|
|
||||||
|
LIVEKIT_API_KEY=linkra_key
|
||||||
|
LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87
|
||||||
|
|
||||||
|
CORS_ALLOW_ORIGINS=https://linkra.ru
|
||||||
15
backend/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
BIN
backend/app/__pycache__/config.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-312.pyc
Normal file
BIN
backend/app/__pycache__/schemas.cpython-312.pyc
Normal file
44
backend/app/config.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_base_url: str = Field(default="http://localhost:8080", alias="APP_BASE_URL")
|
||||||
|
livekit_url: str = Field(default="ws://livekit:7880", alias="LIVEKIT_URL")
|
||||||
|
livekit_public_url: str = Field(default="ws://localhost:7880", alias="LIVEKIT_PUBLIC_URL")
|
||||||
|
livekit_api_key: str = Field(default="devkey", alias="LIVEKIT_API_KEY")
|
||||||
|
livekit_api_secret: str = Field(default="secret", alias="LIVEKIT_API_SECRET")
|
||||||
|
call_token_ttl_hours: int = Field(default=2, alias="CALL_TOKEN_TTL_HOURS")
|
||||||
|
room_empty_timeout_seconds: int = Field(default=600, alias="ROOM_EMPTY_TIMEOUT_SECONDS")
|
||||||
|
max_attachment_size_mb: int = Field(default=100, alias="MAX_ATTACHMENT_SIZE_MB")
|
||||||
|
uploads_dir: str = Field(default="uploads", alias="UPLOADS_DIR")
|
||||||
|
telegram_alerting_enabled: bool = Field(default=False, alias="TELEGRAM_ALERTING_ENABLED")
|
||||||
|
telegram_bot_token: str = Field(default="", alias="TELEGRAM_BOT_TOKEN")
|
||||||
|
telegram_chat_id: str = Field(default="", alias="TELEGRAM_CHAT_ID")
|
||||||
|
telegram_topic_id: int | None = Field(default=None, alias="TELEGRAM_TOPIC_ID")
|
||||||
|
block_probe_paths: bool = Field(default=True, alias="BLOCK_PROBE_PATHS")
|
||||||
|
cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS")
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_attachment_size_bytes(self) -> int:
|
||||||
|
return self.max_attachment_size_mb * 1024 * 1024
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uploads_path(self) -> Path:
|
||||||
|
return Path(self.uploads_dir)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telegram_alerting_available(self) -> bool:
|
||||||
|
return bool(self.telegram_alerting_enabled and self.telegram_bot_token.strip() and self.telegram_chat_id.strip())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origins(self) -> list[str]:
|
||||||
|
raw = getattr(self, 'cors_allow_origins', '')
|
||||||
|
return [origin.strip() for origin in raw.split(',') if origin.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
45
backend/app/livekit_service.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from livekit import api
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
async def create_room_if_needed(room_name: str) -> None:
|
||||||
|
lkapi = api.LiveKitAPI(
|
||||||
|
url=settings.livekit_url,
|
||||||
|
api_key=settings.livekit_api_key,
|
||||||
|
api_secret=settings.livekit_api_secret,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
rooms = await lkapi.room.list_rooms(api.ListRoomsRequest(names=[room_name]))
|
||||||
|
if rooms.rooms:
|
||||||
|
return
|
||||||
|
await lkapi.room.create_room(
|
||||||
|
api.CreateRoomRequest(
|
||||||
|
name=room_name,
|
||||||
|
empty_timeout=settings.room_empty_timeout_seconds,
|
||||||
|
max_participants=20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def build_participant_token(*, room_name: str, identity: str, display_name: str) -> str:
|
||||||
|
token = (
|
||||||
|
api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name(display_name)
|
||||||
|
.with_ttl(timedelta(hours=settings.call_token_ttl_hours))
|
||||||
|
.with_grants(
|
||||||
|
api.VideoGrants(
|
||||||
|
room_join=True,
|
||||||
|
room=room_name,
|
||||||
|
can_publish=True,
|
||||||
|
can_subscribe=True,
|
||||||
|
can_update_own_metadata=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return token.to_jwt()
|
||||||
495
backend/app/main.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import secrets
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from shutil import rmtree
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .livekit_service import build_participant_token, create_room_if_needed
|
||||||
|
from .models import AttachmentRecord, CallRecord, ChatEventRecord
|
||||||
|
from .schemas import (
|
||||||
|
AttachmentListResponse,
|
||||||
|
AttachmentResponse,
|
||||||
|
AppConfigResponse,
|
||||||
|
CallInfoResponse,
|
||||||
|
ChatAuthorSyncRequest,
|
||||||
|
ChatMessageLogRequest,
|
||||||
|
CreateCallRequest,
|
||||||
|
CreateCallResponse,
|
||||||
|
FinishCallRequest,
|
||||||
|
JoinCallRequest,
|
||||||
|
JoinCallResponse,
|
||||||
|
)
|
||||||
|
from .probe_blocker import ProbeBlockMiddleware
|
||||||
|
from .store import store
|
||||||
|
|
||||||
|
app = FastAPI(title="Video Call MVP")
|
||||||
|
app.add_middleware(ProbeBlockMiddleware)
|
||||||
|
if settings.cors_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.uploads_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def public_app_url() -> str:
|
||||||
|
return settings.app_base_url.strip().rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def build_attachment_response(room_name: str, attachment: AttachmentRecord) -> AttachmentResponse:
|
||||||
|
return AttachmentResponse(
|
||||||
|
attachment_id=attachment.attachment_id,
|
||||||
|
file_name=attachment.original_name,
|
||||||
|
content_type=attachment.content_type,
|
||||||
|
size_bytes=attachment.size_bytes,
|
||||||
|
download_url=f"/api/calls/{room_name}/attachments/{attachment.attachment_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_call_access(room_name: str, invite_token: str) -> CallRecord:
|
||||||
|
call = store.get(room_name)
|
||||||
|
if call is None or not call.is_active:
|
||||||
|
raise HTTPException(status_code=404, detail="Call not found")
|
||||||
|
if invite_token != call.invite_token:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid invite token")
|
||||||
|
return call
|
||||||
|
|
||||||
|
|
||||||
|
def room_upload_dir(room_name: str) -> Path:
|
||||||
|
return settings.uploads_path / room_name
|
||||||
|
|
||||||
|
|
||||||
|
def human_dt(value: datetime) -> str:
|
||||||
|
return value.astimezone().strftime("%d.%m.%Y %H:%M:%S %Z")
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: int) -> str:
|
||||||
|
minutes, secs = divmod(max(0, seconds), 60)
|
||||||
|
hours, minutes = divmod(minutes, 60)
|
||||||
|
if hours:
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def split_text_chunks(text: str, limit: int = 3500) -> list[str]:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return [text]
|
||||||
|
chunks: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for line in text.splitlines():
|
||||||
|
candidate = f"{current}\n{line}".strip() if current else line
|
||||||
|
if len(candidate) > limit and current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = line
|
||||||
|
elif len(line) > limit:
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = ""
|
||||||
|
for start in range(0, len(line), limit):
|
||||||
|
chunks.append(line[start:start + limit])
|
||||||
|
else:
|
||||||
|
current = candidate
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_targets() -> list[dict[str, str | int]]:
|
||||||
|
chat_id = settings.telegram_chat_id.strip()
|
||||||
|
if settings.telegram_topic_id is not None:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_thread_id": settings.telegram_topic_id,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return [{"chat_id": chat_id}]
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_request(
|
||||||
|
method: str,
|
||||||
|
fields: dict[str, str | int],
|
||||||
|
file_path: Path | None = None,
|
||||||
|
file_name: str | None = None,
|
||||||
|
content_type: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
url = f"https://api.telegram.org/bot{settings.telegram_bot_token.strip()}/{method}"
|
||||||
|
if file_path is None:
|
||||||
|
data = urllib.parse.urlencode({k: v for k, v in fields.items() if v is not None}).encode("utf-8")
|
||||||
|
request = urllib.request.Request(url, data=data, method="POST")
|
||||||
|
else:
|
||||||
|
boundary = f"----ChatGPTBoundary{uuid4().hex}"
|
||||||
|
body = bytearray()
|
||||||
|
for key, value in fields.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
body.extend(f"--{boundary}\r\n".encode())
|
||||||
|
body.extend(f'Content-Disposition: form-data; name="{key}"\r\n\r\n{value}\r\n'.encode("utf-8"))
|
||||||
|
guessed_type = content_type or mimetypes.guess_type(file_name or file_path.name)[0] or "application/octet-stream"
|
||||||
|
body.extend(f"--{boundary}\r\n".encode())
|
||||||
|
body.extend(
|
||||||
|
f'Content-Disposition: form-data; name="document"; filename="{file_name or file_path.name}"\r\n'.encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
body.extend(f"Content-Type: {guessed_type}\r\n\r\n".encode("utf-8"))
|
||||||
|
body.extend(file_path.read_bytes())
|
||||||
|
body.extend(b"\r\n")
|
||||||
|
body.extend(f"--{boundary}--\r\n".encode())
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=bytes(body),
|
||||||
|
method="POST",
|
||||||
|
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=20) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
if not payload.get("ok"):
|
||||||
|
raise RuntimeError(payload.get("description") or "Telegram API error")
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_text(text: str) -> None:
|
||||||
|
for target in telegram_targets():
|
||||||
|
for chunk in split_text_chunks(text):
|
||||||
|
telegram_request(
|
||||||
|
"sendMessage",
|
||||||
|
{
|
||||||
|
**target,
|
||||||
|
"text": chunk,
|
||||||
|
"disable_web_page_preview": "true",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_document(file_path: Path, attachment: AttachmentRecord, caption: str | None = None) -> None:
|
||||||
|
for target in telegram_targets():
|
||||||
|
telegram_request(
|
||||||
|
"sendDocument",
|
||||||
|
{
|
||||||
|
**target,
|
||||||
|
"caption": caption or attachment.original_name,
|
||||||
|
},
|
||||||
|
file_path=file_path,
|
||||||
|
file_name=attachment.original_name,
|
||||||
|
content_type=attachment.content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_finish_alert(call: CallRecord) -> None:
|
||||||
|
if not (settings.telegram_alerting_available and call.telegram_alert_enabled):
|
||||||
|
return
|
||||||
|
|
||||||
|
started_at = call.started_at or call.created_at
|
||||||
|
finished_at = datetime.now(started_at.tzinfo)
|
||||||
|
duration_seconds = int((finished_at - started_at).total_seconds()) if started_at else 0
|
||||||
|
|
||||||
|
if duration_seconds < 60:
|
||||||
|
return
|
||||||
|
if len(call.participant_names) < 2:
|
||||||
|
return
|
||||||
|
if call.started_at is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
participants = ", ".join(call.participant_names) if call.participant_names else "—"
|
||||||
|
|
||||||
|
summary = "\n".join(
|
||||||
|
[
|
||||||
|
f"Итоги созвона: {call.room_title}",
|
||||||
|
f"Комната: {call.room_name}",
|
||||||
|
f"Создана: {human_dt(call.created_at)}",
|
||||||
|
f"Таймер созвона: {format_duration(duration_seconds)}",
|
||||||
|
f"Участники: {participants}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
send_telegram_text(summary)
|
||||||
|
|
||||||
|
transcript_lines: list[str] = []
|
||||||
|
for event in call.chat_events:
|
||||||
|
timestamp = event.created_at.astimezone().strftime("%H:%M:%S")
|
||||||
|
if event.event_type == "text" and event.text:
|
||||||
|
transcript_lines.append(f"[{timestamp}] {event.author}: {event.text}")
|
||||||
|
elif event.event_type == "file":
|
||||||
|
transcript_lines.append(f"[{timestamp}] {event.author}: файл {event.file_name or 'без названия'}")
|
||||||
|
|
||||||
|
if transcript_lines:
|
||||||
|
send_telegram_text("Сообщения и файлы из чата:\n" + "\n".join(transcript_lines))
|
||||||
|
else:
|
||||||
|
send_telegram_text("Сообщения и файлы из чата: нет пользовательских сообщений.")
|
||||||
|
|
||||||
|
attachment_authors = {
|
||||||
|
event.attachment_id: event.author
|
||||||
|
for event in call.chat_events
|
||||||
|
if event.event_type == "file" and event.attachment_id
|
||||||
|
}
|
||||||
|
upload_dir = room_upload_dir(call.room_name)
|
||||||
|
for attachment in call.attachments:
|
||||||
|
file_path = upload_dir / attachment.stored_name
|
||||||
|
if not file_path.exists():
|
||||||
|
continue
|
||||||
|
author = attachment_authors.get(attachment.attachment_id)
|
||||||
|
caption = f"Файл из чата: {attachment.original_name}"
|
||||||
|
if author:
|
||||||
|
caption = f"{caption} — {author}"
|
||||||
|
send_telegram_document(file_path, attachment, caption=caption[:1000])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config", response_model=AppConfigResponse)
|
||||||
|
def get_config() -> AppConfigResponse:
|
||||||
|
return AppConfigResponse(
|
||||||
|
telegram_alerting_available=settings.telegram_alerting_available,
|
||||||
|
max_attachment_size_mb=settings.max_attachment_size_mb,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/i/{short_code}")
|
||||||
|
def invite_short_redirect(request: Request, short_code: str):
|
||||||
|
room_name = store.resolve_short_invite(short_code)
|
||||||
|
if room_name is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
call = store.get(room_name)
|
||||||
|
if call is None or not call.is_active or call.invite_short_code != short_code:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
query = urllib.parse.urlencode({"invite": call.invite_token})
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
return RedirectResponse(url=f"{base}/call/{room_name}?{query}", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calls", response_model=CreateCallResponse)
|
||||||
|
async def create_call(payload: CreateCallRequest | None = None) -> CreateCallResponse:
|
||||||
|
room_name = f"call-{uuid4().hex[:10]}"
|
||||||
|
invite_token = uuid4().hex
|
||||||
|
room_title = (payload.room_title if payload and payload.room_title else room_name)
|
||||||
|
password = payload.password.strip() if payload and payload.password else None
|
||||||
|
telegram_alert_enabled = bool(payload.telegram_alert_enabled) if payload else False
|
||||||
|
quick_join_default = bool(payload.quick_join) if payload else False
|
||||||
|
|
||||||
|
short_code = secrets.token_hex(4)
|
||||||
|
while not store.register_short_invite(short_code, room_name):
|
||||||
|
short_code = secrets.token_hex(4)
|
||||||
|
|
||||||
|
call = CallRecord(
|
||||||
|
room_name=room_name,
|
||||||
|
room_title=room_title,
|
||||||
|
invite_token=invite_token,
|
||||||
|
invite_short_code=short_code,
|
||||||
|
quick_join_default=quick_join_default,
|
||||||
|
password=password or None,
|
||||||
|
telegram_alert_enabled=settings.telegram_alerting_available and telegram_alert_enabled,
|
||||||
|
)
|
||||||
|
store.create(call)
|
||||||
|
await create_room_if_needed(room_name)
|
||||||
|
|
||||||
|
base = public_app_url()
|
||||||
|
invite_link = f"{base}/call/{room_name}?invite={invite_token}"
|
||||||
|
short_invite_link = f"{base}/i/{short_code}"
|
||||||
|
return CreateCallResponse(
|
||||||
|
room_name=room_name,
|
||||||
|
room_title=room_title,
|
||||||
|
invite_token=invite_token,
|
||||||
|
invite_link=invite_link,
|
||||||
|
short_invite_link=short_invite_link,
|
||||||
|
has_password=bool(password),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/calls/{room_name}", response_model=CallInfoResponse)
|
||||||
|
def get_call(room_name: str) -> CallInfoResponse:
|
||||||
|
call = store.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Call not found")
|
||||||
|
return CallInfoResponse(
|
||||||
|
room_name=call.room_name,
|
||||||
|
room_title=call.room_title,
|
||||||
|
is_active=call.is_active,
|
||||||
|
created_at=call.created_at.isoformat(),
|
||||||
|
has_password=bool(call.password),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calls/{room_name}/join", response_model=JoinCallResponse)
|
||||||
|
async def join_call(room_name: str, payload: JoinCallRequest) -> JoinCallResponse:
|
||||||
|
call = store.get(room_name)
|
||||||
|
if call is None or not call.is_active:
|
||||||
|
raise HTTPException(status_code=404, detail="Call not found")
|
||||||
|
if payload.invite_token != call.invite_token:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid invite token")
|
||||||
|
if call.password and payload.password.strip() != call.password:
|
||||||
|
raise HTTPException(status_code=403, detail="Неверный пароль комнаты")
|
||||||
|
|
||||||
|
identity = payload.identity or f"user-{uuid4().hex[:12]}"
|
||||||
|
participant_token = await build_participant_token(
|
||||||
|
room_name=room_name,
|
||||||
|
identity=identity,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
)
|
||||||
|
store.mark_started(room_name)
|
||||||
|
store.add_participant(room_name, payload.display_name)
|
||||||
|
|
||||||
|
return JoinCallResponse(
|
||||||
|
server_url=settings.livekit_public_url,
|
||||||
|
participant_token=participant_token,
|
||||||
|
room_name=room_name,
|
||||||
|
room_title=call.room_title,
|
||||||
|
identity=identity,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calls/{room_name}/chat-events")
|
||||||
|
def log_chat_message(room_name: str, payload: ChatMessageLogRequest) -> dict[str, str]:
|
||||||
|
ensure_call_access(room_name, payload.invite_token)
|
||||||
|
pid = (payload.participant_identity or "").strip() or None
|
||||||
|
store.add_chat_event(
|
||||||
|
room_name,
|
||||||
|
ChatEventRecord(
|
||||||
|
event_type="text",
|
||||||
|
author=payload.display_name.strip(),
|
||||||
|
author_identity=pid,
|
||||||
|
text=payload.text.strip(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calls/{room_name}/chat-author-sync")
|
||||||
|
def sync_chat_author_display(room_name: str, payload: ChatAuthorSyncRequest) -> dict[str, str]:
|
||||||
|
ensure_call_access(room_name, payload.invite_token)
|
||||||
|
name = payload.display_name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Пустое имя")
|
||||||
|
store.update_chat_events_author_by_identity(
|
||||||
|
room_name,
|
||||||
|
payload.participant_identity.strip(),
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/calls/{room_name}/attachments", response_model=AttachmentListResponse)
|
||||||
|
def list_attachments(room_name: str, invite_token: str) -> AttachmentListResponse:
|
||||||
|
ensure_call_access(room_name, invite_token)
|
||||||
|
items = [build_attachment_response(room_name, item) for item in store.list_attachments(room_name)]
|
||||||
|
return AttachmentListResponse(items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calls/{room_name}/attachments", response_model=AttachmentResponse)
|
||||||
|
async def upload_attachment(
|
||||||
|
room_name: str,
|
||||||
|
invite_token: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
display_name: str = Form(default=""),
|
||||||
|
participant_identity: str = Form(default=""),
|
||||||
|
) -> AttachmentResponse:
|
||||||
|
ensure_call_access(room_name, invite_token)
|
||||||
|
filename = (file.filename or "").strip()
|
||||||
|
if not filename:
|
||||||
|
raise HTTPException(status_code=400, detail="Файл не выбран")
|
||||||
|
|
||||||
|
upload_dir = room_upload_dir(room_name)
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
attachment_id = uuid4().hex
|
||||||
|
ext = Path(filename).suffix
|
||||||
|
stored_name = f"{attachment_id}{ext}"
|
||||||
|
destination = upload_dir / stored_name
|
||||||
|
|
||||||
|
size_bytes = 0
|
||||||
|
try:
|
||||||
|
with destination.open("wb") as target:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size_bytes += len(chunk)
|
||||||
|
if size_bytes > settings.max_attachment_size_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"Максимальный размер файла — {settings.max_attachment_size_mb} МБ",
|
||||||
|
)
|
||||||
|
target.write(chunk)
|
||||||
|
except Exception:
|
||||||
|
if destination.exists():
|
||||||
|
destination.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await file.close()
|
||||||
|
|
||||||
|
attachment = AttachmentRecord(
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
original_name=filename,
|
||||||
|
stored_name=stored_name,
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
)
|
||||||
|
store.add_attachment(room_name, attachment)
|
||||||
|
author = display_name.strip() or "Участник"
|
||||||
|
aid = participant_identity.strip() or None
|
||||||
|
store.add_chat_event(
|
||||||
|
room_name,
|
||||||
|
ChatEventRecord(
|
||||||
|
event_type="file",
|
||||||
|
author=author,
|
||||||
|
author_identity=aid,
|
||||||
|
attachment_id=attachment_id,
|
||||||
|
file_name=filename,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return build_attachment_response(room_name, attachment)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/calls/{room_name}/attachments/{attachment_id}")
|
||||||
|
def download_attachment(room_name: str, attachment_id: str, invite_token: str):
|
||||||
|
ensure_call_access(room_name, invite_token)
|
||||||
|
attachment = store.get_attachment(room_name, attachment_id)
|
||||||
|
if attachment is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
file_path = room_upload_dir(room_name) / attachment.stored_name
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Файл не найден")
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=attachment.original_name,
|
||||||
|
media_type=attachment.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/calls/{room_name}/finish")
|
||||||
|
def finish_call(room_name: str, payload: FinishCallRequest) -> dict[str, str]:
|
||||||
|
call = store.get(room_name)
|
||||||
|
if call is None or not call.is_active:
|
||||||
|
raise HTTPException(status_code=404, detail="Call not found")
|
||||||
|
if payload.invite_token != call.invite_token:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid invite token")
|
||||||
|
|
||||||
|
alert_status = "disabled"
|
||||||
|
if settings.telegram_alerting_available and call.telegram_alert_enabled:
|
||||||
|
try:
|
||||||
|
send_finish_alert(call)
|
||||||
|
alert_status = "sent"
|
||||||
|
except Exception:
|
||||||
|
alert_status = "failed"
|
||||||
|
|
||||||
|
store.clear_attachments(room_name)
|
||||||
|
upload_dir = room_upload_dir(room_name)
|
||||||
|
if upload_dir.exists():
|
||||||
|
rmtree(upload_dir, ignore_errors=True)
|
||||||
|
store.deactivate(room_name)
|
||||||
|
return {"status": "finished", "telegram_alert_status": alert_status}
|
||||||
42
backend/app/models.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentRecord(BaseModel):
|
||||||
|
attachment_id: str
|
||||||
|
original_name: str
|
||||||
|
stored_name: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatEventRecord(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
author: str
|
||||||
|
author_identity: str | None = None
|
||||||
|
text: str | None = None
|
||||||
|
attachment_id: str | None = None
|
||||||
|
file_name: str | None = None
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class CallRecord(BaseModel):
|
||||||
|
room_name: str
|
||||||
|
room_title: str
|
||||||
|
invite_token: str
|
||||||
|
invite_short_code: str | None = None
|
||||||
|
quick_join_default: bool = False
|
||||||
|
password: str | None = None
|
||||||
|
telegram_alert_enabled: bool = False
|
||||||
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
|
started_at: datetime | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
participant_names: list[str] = Field(default_factory=list)
|
||||||
|
chat_events: list[ChatEventRecord] = Field(default_factory=list)
|
||||||
|
attachments: list[AttachmentRecord] = Field(default_factory=list)
|
||||||
37
backend/app/probe_blocker.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
_PROBE_PATH_RE = re.compile(
|
||||||
|
r"""(?ix)
|
||||||
|
\.(?:php|phtml|phar|sh|sql|bak|old|orig|swp|zip|tar|gz)(?:\?|$|/)|
|
||||||
|
/\.(?:env|git|svn|hg|bzr|htpasswd|htaccess)(?:/|$)|
|
||||||
|
(?:^|/)\.env(?:\.|\?|$|/)|
|
||||||
|
[^/]+\.env(?:\?|$|/)|
|
||||||
|
(?:^|/)(?:wp-admin|wp-includes|wp-content|wp-login\.php|xmlrpc\.php)(?:/|$)|
|
||||||
|
(?:^|/)(?:phpmyadmin|pma|adminer|vendor/phpunit)(?:/|$)|
|
||||||
|
(?:^|/)cgi-bin(?:/|$)|
|
||||||
|
(?:^|/)(?:wlwmanifest\.xml|readme\.html|license\.txt)(?:/|$)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def suspicious_request_path(path: str) -> bool:
|
||||||
|
return bool(_PROBE_PATH_RE.search(path))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ProbeBlockMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
if not settings.block_probe_paths:
|
||||||
|
return await call_next(request)
|
||||||
|
p = request.url.path
|
||||||
|
if p.startswith('/static/'):
|
||||||
|
return await call_next(request)
|
||||||
|
if suspicious_request_path(p):
|
||||||
|
return Response(status_code=404, content=b'Not Found', media_type='text/plain')
|
||||||
|
return await call_next(request)
|
||||||
79
backend/app/schemas.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCallRequest(BaseModel):
|
||||||
|
room_title: str = Field(default="", max_length=100)
|
||||||
|
password: str = Field(default="", max_length=100)
|
||||||
|
telegram_alert_enabled: bool = False
|
||||||
|
quick_join: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CreateCallResponse(BaseModel):
|
||||||
|
room_name: str
|
||||||
|
room_title: str
|
||||||
|
invite_token: str
|
||||||
|
invite_link: str
|
||||||
|
short_invite_link: str
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class JoinCallRequest(BaseModel):
|
||||||
|
invite_token: str = Field(min_length=8)
|
||||||
|
display_name: str = Field(min_length=1, max_length=50)
|
||||||
|
password: str = Field(default="", max_length=100)
|
||||||
|
with_video: bool = True
|
||||||
|
with_audio: bool = True
|
||||||
|
identity: Optional[str] = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class JoinCallResponse(BaseModel):
|
||||||
|
server_url: str
|
||||||
|
participant_token: str
|
||||||
|
room_name: str
|
||||||
|
room_title: str
|
||||||
|
identity: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class CallInfoResponse(BaseModel):
|
||||||
|
room_name: str
|
||||||
|
room_title: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: str
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfigResponse(BaseModel):
|
||||||
|
telegram_alerting_available: bool
|
||||||
|
max_attachment_size_mb: int
|
||||||
|
|
||||||
|
|
||||||
|
class FinishCallRequest(BaseModel):
|
||||||
|
invite_token: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentResponse(BaseModel):
|
||||||
|
attachment_id: str
|
||||||
|
file_name: str
|
||||||
|
content_type: str
|
||||||
|
size_bytes: int
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentListResponse(BaseModel):
|
||||||
|
items: list[AttachmentResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageLogRequest(BaseModel):
|
||||||
|
invite_token: str = Field(min_length=8)
|
||||||
|
display_name: str = Field(min_length=1, max_length=50)
|
||||||
|
text: str = Field(min_length=1, max_length=1000)
|
||||||
|
participant_identity: Optional[str] = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatAuthorSyncRequest(BaseModel):
|
||||||
|
invite_token: str = Field(min_length=8)
|
||||||
|
participant_identity: str = Field(min_length=1, max_length=64)
|
||||||
|
display_name: str = Field(min_length=1, max_length=50)
|
||||||
127
backend/app/store.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from threading import Lock
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .models import AttachmentRecord, CallRecord, ChatEventRecord, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryCallStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = Lock()
|
||||||
|
self._calls: dict[str, CallRecord] = {}
|
||||||
|
self._short_to_room: dict[str, str] = {}
|
||||||
|
|
||||||
|
def create(self, call: CallRecord) -> CallRecord:
|
||||||
|
with self._lock:
|
||||||
|
self._calls[call.room_name] = call
|
||||||
|
return call
|
||||||
|
|
||||||
|
def register_short_invite(self, short_code: str, room_name: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if short_code in self._short_to_room:
|
||||||
|
return False
|
||||||
|
self._short_to_room[short_code] = room_name
|
||||||
|
return True
|
||||||
|
|
||||||
|
def resolve_short_invite(self, short_code: str) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
return self._short_to_room.get(short_code)
|
||||||
|
|
||||||
|
def get(self, room_name: str) -> Optional[CallRecord]:
|
||||||
|
with self._lock:
|
||||||
|
return self._calls.get(room_name)
|
||||||
|
|
||||||
|
def deactivate(self, room_name: str) -> Optional[CallRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
if call.invite_short_code:
|
||||||
|
self._short_to_room.pop(call.invite_short_code, None)
|
||||||
|
updated = call.model_copy(update={"is_active": False})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def mark_started(self, room_name: str):
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None or call.started_at is not None:
|
||||||
|
return call
|
||||||
|
updated = call.model_copy(update={"started_at": utcnow()})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def add_participant(self, room_name: str, display_name: str):
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
name = display_name.strip()
|
||||||
|
if not name or name in call.participant_names:
|
||||||
|
return call
|
||||||
|
updated = call.model_copy(update={"participant_names": [*call.participant_names, name]})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def add_chat_event(self, room_name: str, event: ChatEventRecord) -> Optional[CallRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
updated = call.model_copy(update={"chat_events": [*call.chat_events, event]})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def update_chat_events_author_by_identity(
|
||||||
|
self, room_name: str, participant_identity: str, display_name: str
|
||||||
|
) -> Optional[CallRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
new_events: list[ChatEventRecord] = []
|
||||||
|
for ev in call.chat_events:
|
||||||
|
if ev.author_identity and ev.author_identity == participant_identity:
|
||||||
|
new_events.append(ev.model_copy(update={"author": display_name}))
|
||||||
|
else:
|
||||||
|
new_events.append(ev)
|
||||||
|
updated = call.model_copy(update={"chat_events": new_events})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def add_attachment(self, room_name: str, attachment: AttachmentRecord) -> Optional[CallRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
updated = call.model_copy(update={"attachments": [*call.attachments, attachment]})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
def list_attachments(self, room_name: str) -> list[AttachmentRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return []
|
||||||
|
return list(call.attachments)
|
||||||
|
|
||||||
|
def get_attachment(self, room_name: str, attachment_id: str) -> Optional[AttachmentRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
for attachment in call.attachments:
|
||||||
|
if attachment.attachment_id == attachment_id:
|
||||||
|
return attachment
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_attachments(self, room_name: str) -> Optional[CallRecord]:
|
||||||
|
with self._lock:
|
||||||
|
call = self._calls.get(room_name)
|
||||||
|
if call is None:
|
||||||
|
return None
|
||||||
|
updated = call.model_copy(update={"attachments": []})
|
||||||
|
self._calls[room_name] = updated
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
store = InMemoryCallStore()
|
||||||
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
livekit-api
|
||||||
|
pydantic-settings
|
||||||
|
python-multipart
|
||||||
BIN
backend/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
backend/static/icons/attachment.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
backend/static/icons/camera_off.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
backend/static/icons/camera_on.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
backend/static/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
backend/static/icons/favicon.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
backend/static/icons/micro_off.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
backend/static/icons/micro_on.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
51
docker-compose.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
livekit:
|
||||||
|
image: livekit/livekit-server:latest
|
||||||
|
container_name: linkra-livekit
|
||||||
|
command: --config /etc/livekit.yaml
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7881:7881/tcp"
|
||||||
|
- "7882:7882/udp"
|
||||||
|
- "127.0.0.1:7880:7880"
|
||||||
|
volumes:
|
||||||
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
|
networks:
|
||||||
|
- linkra_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:7880"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
container_name: linkra-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
depends_on:
|
||||||
|
livekit:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- linkra_net
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
container_name: linkra-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- linkra_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
linkra_net:
|
||||||
|
driver: bridge
|
||||||
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
npm-debug.log
|
||||||
16
frontend/Dockerfile
Normal 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
@@ -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
@@ -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
17
frontend/package.json
Normal 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
@@ -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
@@ -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
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
11
livekit.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
port: 7880
|
||||||
|
bind_addresses:
|
||||||
|
- "0.0.0.0"
|
||||||
|
keys:
|
||||||
|
linkra_key: "8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87"
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
udp_port: 7882
|
||||||
|
use_external_ip: true
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||