Compare commits
3 Commits
c1ef5871c2
...
65c464ab67
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c464ab67 | |||
| 1ae330258b | |||
| 50eec199ca |
@@ -7,3 +7,7 @@ LIVEKIT_API_KEY=linkra_key
|
|||||||
LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87
|
LIVEKIT_API_SECRET=8edc959cc8517b864f73c99c86305a1d3b3fe509309e0a6e6e35172cc6695d87
|
||||||
|
|
||||||
CORS_ALLOW_ORIGINS=https://linkra.ru
|
CORS_ALLOW_ORIGINS=https://linkra.ru
|
||||||
|
|
||||||
|
TURN_URLS=turn:72.56.247.116:3478?transport=udp,turn:72.56.247.116:3478?transport=tcp
|
||||||
|
TURN_USERNAME=linkra
|
||||||
|
TURN_CREDENTIAL=change-me
|
||||||
145
.gitignore
vendored
Normal file
145
.gitignore
vendored
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
backend/app/__pycache__/
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
*.pyd
|
||||||
|
*.dll
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache/
|
||||||
|
.pytest_cache/
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Type checkers / linters
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
.pyre/
|
||||||
|
.pytype/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# Pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# Hatch
|
||||||
|
.hatch/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Local databases
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Secrets / credentials
|
||||||
|
secrets.json
|
||||||
|
credentials.json
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# PyCharm / IntelliJ IDEA
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
out/
|
||||||
|
|
||||||
|
# VS Code (optional)
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Sphinx docs
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# mkdocs
|
||||||
|
site/
|
||||||
|
|
||||||
|
# celery
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# mypy compiled cache
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# pyinstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# pytest debug
|
||||||
|
pytestdebug.log
|
||||||
|
|
||||||
|
# Local config overrides
|
||||||
|
config.local.py
|
||||||
|
settings.local.py
|
||||||
|
|
||||||
|
# Vault / local dev secrets
|
||||||
|
.env.vault
|
||||||
|
vault.token
|
||||||
|
|
||||||
|
.env
|
||||||
|
.dockerignore
|
||||||
|
/sql
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,12 +14,11 @@ class Settings(BaseSettings):
|
|||||||
room_empty_timeout_seconds: int = Field(default=600, alias="ROOM_EMPTY_TIMEOUT_SECONDS")
|
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")
|
max_attachment_size_mb: int = Field(default=100, alias="MAX_ATTACHMENT_SIZE_MB")
|
||||||
uploads_dir: str = Field(default="uploads", alias="UPLOADS_DIR")
|
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")
|
block_probe_paths: bool = Field(default=True, alias="BLOCK_PROBE_PATHS")
|
||||||
cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS")
|
cors_allow_origins: str = Field(default="", alias="CORS_ALLOW_ORIGINS")
|
||||||
|
turn_urls: str = Field(default="", alias="TURN_URLS")
|
||||||
|
turn_username: str = Field(default="", alias="TURN_USERNAME")
|
||||||
|
turn_credential: str = Field(default="", alias="TURN_CREDENTIAL")
|
||||||
|
|
||||||
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
model_config = SettingsConfigDict(case_sensitive=False, extra="ignore")
|
||||||
|
|
||||||
@@ -31,14 +30,23 @@ class Settings(BaseSettings):
|
|||||||
def uploads_path(self) -> Path:
|
def uploads_path(self) -> Path:
|
||||||
return Path(self.uploads_dir)
|
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
|
@property
|
||||||
def cors_origins(self) -> list[str]:
|
def cors_origins(self) -> list[str]:
|
||||||
raw = getattr(self, 'cors_allow_origins', '')
|
raw = getattr(self, 'cors_allow_origins', '')
|
||||||
return [origin.strip() for origin in raw.split(',') if origin.strip()]
|
return [origin.strip() for origin in raw.split(',') if origin.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def turn_ice_servers(self) -> list[dict[str, str | list[str]]]:
|
||||||
|
urls = [url.strip() for url in self.turn_urls.split(',') if url.strip()]
|
||||||
|
if not urls or not self.turn_username.strip() or not self.turn_credential.strip():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'urls': urls,
|
||||||
|
'username': self.turn_username.strip(),
|
||||||
|
'credential': self.turn_credential.strip(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import json
|
|
||||||
import mimetypes
|
|
||||||
import secrets
|
import secrets
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -72,180 +68,6 @@ def room_upload_dir(room_name: str) -> Path:
|
|||||||
return settings.uploads_path / room_name
|
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")
|
@app.get("/health")
|
||||||
def health() -> dict[str, str]:
|
def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
@@ -254,8 +76,8 @@ def health() -> dict[str, str]:
|
|||||||
@app.get("/api/config", response_model=AppConfigResponse)
|
@app.get("/api/config", response_model=AppConfigResponse)
|
||||||
def get_config() -> AppConfigResponse:
|
def get_config() -> AppConfigResponse:
|
||||||
return AppConfigResponse(
|
return AppConfigResponse(
|
||||||
telegram_alerting_available=settings.telegram_alerting_available,
|
|
||||||
max_attachment_size_mb=settings.max_attachment_size_mb,
|
max_attachment_size_mb=settings.max_attachment_size_mb,
|
||||||
|
turn_ice_servers=settings.turn_ice_servers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -278,7 +100,6 @@ async def create_call(payload: CreateCallRequest | None = None) -> CreateCallRes
|
|||||||
invite_token = uuid4().hex
|
invite_token = uuid4().hex
|
||||||
room_title = (payload.room_title if payload and payload.room_title else room_name)
|
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
|
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
|
quick_join_default = bool(payload.quick_join) if payload else False
|
||||||
|
|
||||||
short_code = secrets.token_hex(4)
|
short_code = secrets.token_hex(4)
|
||||||
@@ -292,7 +113,6 @@ async def create_call(payload: CreateCallRequest | None = None) -> CreateCallRes
|
|||||||
invite_short_code=short_code,
|
invite_short_code=short_code,
|
||||||
quick_join_default=quick_join_default,
|
quick_join_default=quick_join_default,
|
||||||
password=password or None,
|
password=password or None,
|
||||||
telegram_alert_enabled=settings.telegram_alerting_available and telegram_alert_enabled,
|
|
||||||
)
|
)
|
||||||
store.create(call)
|
store.create(call)
|
||||||
await create_room_if_needed(room_name)
|
await create_room_if_needed(room_name)
|
||||||
@@ -479,17 +299,9 @@ def finish_call(room_name: str, payload: FinishCallRequest) -> dict[str, str]:
|
|||||||
if payload.invite_token != call.invite_token:
|
if payload.invite_token != call.invite_token:
|
||||||
raise HTTPException(status_code=403, detail="Invalid 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)
|
store.clear_attachments(room_name)
|
||||||
upload_dir = room_upload_dir(room_name)
|
upload_dir = room_upload_dir(room_name)
|
||||||
if upload_dir.exists():
|
if upload_dir.exists():
|
||||||
rmtree(upload_dir, ignore_errors=True)
|
rmtree(upload_dir, ignore_errors=True)
|
||||||
store.deactivate(room_name)
|
store.deactivate(room_name)
|
||||||
return {"status": "finished", "telegram_alert_status": alert_status}
|
return {"status": "finished"}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class CallRecord(BaseModel):
|
|||||||
invite_short_code: str | None = None
|
invite_short_code: str | None = None
|
||||||
quick_join_default: bool = False
|
quick_join_default: bool = False
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
telegram_alert_enabled: bool = False
|
|
||||||
created_at: datetime = Field(default_factory=utcnow)
|
created_at: datetime = Field(default_factory=utcnow)
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from pydantic import BaseModel, Field
|
|||||||
class CreateCallRequest(BaseModel):
|
class CreateCallRequest(BaseModel):
|
||||||
room_title: str = Field(default="", max_length=100)
|
room_title: str = Field(default="", max_length=100)
|
||||||
password: str = Field(default="", max_length=100)
|
password: str = Field(default="", max_length=100)
|
||||||
telegram_alert_enabled: bool = False
|
|
||||||
quick_join: bool = False
|
quick_join: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -46,8 +45,8 @@ class CallInfoResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class AppConfigResponse(BaseModel):
|
class AppConfigResponse(BaseModel):
|
||||||
telegram_alerting_available: bool
|
|
||||||
max_attachment_size_mb: int
|
max_attachment_size_mb: int
|
||||||
|
turn_ice_servers: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class FinishCallRequest(BaseModel):
|
class FinishCallRequest(BaseModel):
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "7881:7881/tcp"
|
- "7881:7881/tcp"
|
||||||
- "7882:7882/udp"
|
- "7882:7882/udp"
|
||||||
- "127.0.0.1:7880:7880"
|
- "7880:7880"
|
||||||
volumes:
|
volumes:
|
||||||
- ./livekit.yaml:/etc/livekit.yaml:ro
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
networks:
|
networks:
|
||||||
@@ -40,7 +40,7 @@ services:
|
|||||||
container_name: linkra-frontend
|
container_name: linkra-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:80"
|
- "2080:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset='UTF-8' />
|
<meta charset='UTF-8' />
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />
|
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />
|
||||||
<meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' />
|
<meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' />
|
||||||
|
<link rel='icon' type='image/png' href='/icons/favicon.png' />
|
||||||
|
<link rel='apple-touch-icon' href='/icons/favicon.png' />
|
||||||
<title>Linkra</title>
|
<title>Linkra</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@@ -21,29 +21,6 @@
|
|||||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
|
|||||||
BIN
frontend/public/icons/attachment.png
Normal file
BIN
frontend/public/icons/attachment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
frontend/public/icons/camera_off.png
Normal file
BIN
frontend/public/icons/camera_off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/icons/camera_on.png
Normal file
BIN
frontend/public/icons/camera_on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/public/icons/favicon.png
Normal file
BIN
frontend/public/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
frontend/public/icons/micro_off.png
Normal file
BIN
frontend/public/icons/micro_off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/public/icons/micro_on.png
Normal file
BIN
frontend/public/icons/micro_on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -5,7 +5,13 @@ import QRCode from'qrcode';
|
|||||||
import'./styles.css';
|
import'./styles.css';
|
||||||
|
|
||||||
|
|
||||||
const TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert';
|
const ICONS = {
|
||||||
|
attachment: '/icons/attachment.png',
|
||||||
|
cameraOff: '/icons/camera_off.png',
|
||||||
|
cameraOn: '/icons/camera_on.png',
|
||||||
|
micOff: '/icons/micro_off.png',
|
||||||
|
micOn: '/icons/micro_on.png'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
function apiUrl(path) {
|
function apiUrl(path) {
|
||||||
@@ -103,9 +109,6 @@ function ErrorPage({ code }) {
|
|||||||
|
|
||||||
|
|
||||||
function HomePage({ showToast }) {
|
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 [roomTitle, setRoomTitle] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -115,13 +118,8 @@ function HomePage({ showToast }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Созвон по ссылке · Linkra';
|
document.title = 'Созвон по ссылке · Linkra';
|
||||||
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(TELEGRAM_PREF_KEY, telegramAlert ? '1' : '0');
|
|
||||||
}, [telegramAlert]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!result || !qrRef.current) return;
|
if (!result || !qrRef.current) return;
|
||||||
const target = result.short_invite_link || result.invite_link;
|
const target = result.short_invite_link || result.invite_link;
|
||||||
@@ -149,7 +147,6 @@ function HomePage({ showToast }) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
room_title: title,
|
room_title: title,
|
||||||
password: roomPassword,
|
password: roomPassword,
|
||||||
telegram_alert_enabled: config.telegram_alerting_available && telegramAlert,
|
|
||||||
quick_join: quickJoin
|
quick_join: quickJoin
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -205,19 +202,6 @@ function HomePage({ showToast }) {
|
|||||||
<section className='card home-card'>
|
<section className='card home-card'>
|
||||||
<div className='home-card-header'>
|
<div className='home-card-header'>
|
||||||
<h2>Новая комната</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
{!result ? (
|
{!result ? (
|
||||||
@@ -262,7 +246,7 @@ function HomePage({ showToast }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function MediaTrack({ track, kind, muted = false, className = '' }) {
|
function MediaTrack({ track, muted = false, className = '' }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,16 +257,49 @@ function MediaTrack({ track, kind, muted = false, className = '' }) {
|
|||||||
};
|
};
|
||||||
}, [track]);
|
}, [track]);
|
||||||
|
|
||||||
if (kind === 'audio') return <audio ref={ref} autoPlay playsInline muted={muted} className={className} />;
|
|
||||||
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
|
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function VideoTile({ item, active }) {
|
function RemoteAudioTrack({ track, volume }) {
|
||||||
|
const gainRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!track?.mediaStreamTrack) return undefined;
|
||||||
|
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioContextClass) return undefined;
|
||||||
|
const context = new AudioContextClass();
|
||||||
|
const stream = new MediaStream([track.mediaStreamTrack]);
|
||||||
|
const source = context.createMediaStreamSource(stream);
|
||||||
|
const gain = context.createGain();
|
||||||
|
gain.gain.value = volume;
|
||||||
|
source.connect(gain);
|
||||||
|
gain.connect(context.destination);
|
||||||
|
gainRef.current = gain;
|
||||||
|
context.resume().catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source.disconnect();
|
||||||
|
gain.disconnect();
|
||||||
|
gainRef.current = null;
|
||||||
|
context.close().catch(() => {});
|
||||||
|
};
|
||||||
|
}, [track]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (gainRef.current) gainRef.current.gain.value = volume;
|
||||||
|
}, [volume]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function VideoTile({ item, active, volume, onVolumeChange }) {
|
||||||
const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare);
|
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 screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare);
|
||||||
const audio = item.tracks.filter((entry) => entry.kind === 'audio');
|
const audio = item.tracks.filter((entry) => entry.kind === 'audio');
|
||||||
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
|
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
|
||||||
|
const volumePercent = Math.round(volume * 100);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
|
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
|
||||||
@@ -290,14 +307,28 @@ function VideoTile({ item, active }) {
|
|||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
|
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!item.isLocal ? (
|
||||||
|
<label className='participant-volume'>
|
||||||
|
<span>Громкость: {volumePercent}%</span>
|
||||||
|
<input
|
||||||
|
aria-label={`Громкость участника ${item.name}`}
|
||||||
|
max='2'
|
||||||
|
min='0'
|
||||||
|
onChange={(event) => onVolumeChange(item.identity, Number(event.target.value))}
|
||||||
|
step='0.05'
|
||||||
|
type='range'
|
||||||
|
value={volume}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
{screen ? (
|
{screen ? (
|
||||||
<div className='screen-slot'>
|
<div className='screen-slot'>
|
||||||
<MediaTrack track={screen.track} kind='video' className='screen-share-video' muted={item.isLocal} />
|
<MediaTrack track={screen.track} className='screen-share-video' muted={item.isLocal} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className='media-slot'>
|
<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>}
|
{camera ? <MediaTrack track={camera.track} 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' />)}
|
{audio.map((entry) => item.isLocal ? null : <RemoteAudioTrack key={entry.id} track={entry.track} volume={volume} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -314,10 +345,11 @@ function CallPage({ roomName, showToast }) {
|
|||||||
const [joining, setJoining] = useState(false);
|
const [joining, setJoining] = useState(false);
|
||||||
const [room, setRoom] = useState(null);
|
const [room, setRoom] = useState(null);
|
||||||
const [participants, setParticipants] = useState([]);
|
const [participants, setParticipants] = useState([]);
|
||||||
|
const [participantVolumes, setParticipantVolumes] = useState({});
|
||||||
const [activeSpeakers, setActiveSpeakers] = useState([]);
|
const [activeSpeakers, setActiveSpeakers] = useState([]);
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [chatText, setChatText] = useState('');
|
const [chatText, setChatText] = useState('');
|
||||||
const [config, setConfig] = useState({ max_attachment_size_mb: 100 });
|
const [config, setConfig] = useState({ max_attachment_size_mb: 100, turn_ice_servers: [] });
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const previewRef = useRef(null);
|
const previewRef = useRef(null);
|
||||||
const previewStreamRef = useRef(null);
|
const previewStreamRef = useRef(null);
|
||||||
@@ -431,6 +463,14 @@ function CallPage({ roomName, showToast }) {
|
|||||||
await room.localParticipant.publishData(encoded, { reliable: true });
|
await room.localParticipant.publishData(encoded, { reliable: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateParticipantVolume = useCallback((identity, volume) => {
|
||||||
|
const safeVolume = Math.min(2, Math.max(0, volume));
|
||||||
|
setParticipantVolumes((current) => ({
|
||||||
|
...current,
|
||||||
|
[identity]: safeVolume
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const joinCall = async () => {
|
const joinCall = async () => {
|
||||||
const name = displayName.trim();
|
const name = displayName.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -493,7 +533,13 @@ function CallPage({ roomName, showToast }) {
|
|||||||
setRoom(null);
|
setRoom(null);
|
||||||
setParticipants([]);
|
setParticipants([]);
|
||||||
});
|
});
|
||||||
await nextRoom.connect(data.server_url, data.participant_token);
|
const connectOptions = {};
|
||||||
|
if (config.turn_ice_servers?.length) {
|
||||||
|
connectOptions.rtcConfig = {
|
||||||
|
iceServers: config.turn_ice_servers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await nextRoom.connect(data.server_url, data.participant_token, connectOptions);
|
||||||
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
|
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
|
||||||
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
|
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
|
||||||
setRoom(nextRoom);
|
setRoom(nextRoom);
|
||||||
@@ -647,8 +693,14 @@ function CallPage({ roomName, showToast }) {
|
|||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
<div className='row'>
|
<div className='row'>
|
||||||
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</button>
|
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>
|
||||||
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</button>
|
<img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
|
||||||
|
<span>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</span>
|
||||||
|
</button>
|
||||||
|
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>
|
||||||
|
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
|
||||||
|
<span>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
|
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -664,15 +716,27 @@ function CallPage({ roomName, showToast }) {
|
|||||||
<p className='muted'>В комнате: {participants.length}</p>
|
<p className='muted'>В комнате: {participants.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='meeting-controls'>
|
<div className='meeting-controls'>
|
||||||
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic}>Mic</button>
|
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic} aria-label={micEnabled ? 'Выключить микрофон' : 'Включить микрофон'}>
|
||||||
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera}>Cam</button>
|
<img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
|
||||||
|
</button>
|
||||||
|
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera} aria-label={cameraEnabled ? 'Выключить камеру' : 'Включить камеру'}>
|
||||||
|
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
|
||||||
|
</button>
|
||||||
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
|
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
|
||||||
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
|
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
|
||||||
<div className='meeting-timer'>{time}</div>
|
<div className='meeting-timer'>{time}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='videos'>
|
<div className='videos'>
|
||||||
{participants.map((item) => <VideoTile key={item.identity} item={item} active={activeSpeakers.includes(item.identity)} />)}
|
{participants.map((item) => (
|
||||||
|
<VideoTile
|
||||||
|
active={activeSpeakers.includes(item.identity)}
|
||||||
|
item={item}
|
||||||
|
key={item.identity}
|
||||||
|
onVolumeChange={updateParticipantVolume}
|
||||||
|
volume={participantVolumes[item.identity] ?? 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -690,7 +754,10 @@ function CallPage({ roomName, showToast }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className='chat-form'>
|
<div className='chat-form'>
|
||||||
<input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' />
|
<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])} />
|
<input className='hidden-file-input' ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
|
||||||
|
<button className='icon-btn attachment-btn' type='button' onClick={() => fileRef.current?.click()} aria-label='Прикрепить файл'>
|
||||||
|
<img className='control-icon' src={ICONS.attachment} alt='' />
|
||||||
|
</button>
|
||||||
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
|
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ input {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
padding: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.btn,
|
.btn,
|
||||||
.icon-btn,
|
.icon-btn,
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
@@ -180,6 +185,9 @@ input {
|
|||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
color: white;
|
color: white;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn.small {
|
.icon-btn.small {
|
||||||
@@ -205,6 +213,25 @@ input {
|
|||||||
border: 1px solid #4b5563;
|
border: 1px solid #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn .control-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
.home-actions {
|
.home-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
@@ -382,6 +409,16 @@ input {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.participant-volume {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(15, 23, 42, 0.72);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.screen-slot {
|
.screen-slot {
|
||||||
height: 260px;
|
height: 260px;
|
||||||
background: #020617;
|
background: #020617;
|
||||||
@@ -473,10 +510,24 @@ input {
|
|||||||
|
|
||||||
.chat-form {
|
.chat-form {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
display: grid;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-form input[type='text'] {
|
||||||
|
min-width: 220px;
|
||||||
|
flex: 1 1 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.file-link {
|
.file-link {
|
||||||
color: #93c5fd;
|
color: #93c5fd;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user