init
This commit is contained in:
82
.gitignore
vendored
Normal file
82
.gitignore
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.tox/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.spyderproject
|
||||
.spyproject
|
||||
.ropeproject
|
||||
/site
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.DS_Store
|
||||
media/
|
||||
staticfiles/
|
||||
db.sqlite3
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
dev-dist
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
.vscode
|
||||
celerybeat-schedule
|
||||
celerybeat-schedule-wal
|
||||
37
backend/Dockerfile
Normal file
37
backend/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Установка системных зависимостей
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
gettext \
|
||||
vim \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копирование и установка Python зависимостей
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Дополнительно устанавливаем daphne если его нет в requirements.txt
|
||||
RUN pip install daphne
|
||||
|
||||
# Создание непривилегированного пользователя с определенным UID/GID
|
||||
RUN groupadd -r appuser -g 1000 && useradd -r -g appuser -u 1000 appuser
|
||||
|
||||
# Копирование исходного кода
|
||||
COPY . .
|
||||
|
||||
# Создание директорий для статических файлов, медиа и логов с правильными правами
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/logs && \
|
||||
chown -R appuser:appuser /app && \
|
||||
chmod -R 755 /app/logs
|
||||
|
||||
# Переключение на непривилегированного пользователя
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Команда по умолчанию - запуск через daphne для поддержки WebSocket
|
||||
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "videocall_app.asgi:application"]
|
||||
0
backend/apps/__init__.py
Normal file
0
backend/apps/__init__.py
Normal file
0
backend/apps/authentication/__init__.py
Normal file
0
backend/apps/authentication/__init__.py
Normal file
3
backend/apps/authentication/admin.py
Normal file
3
backend/apps/authentication/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/apps/authentication/apps.py
Normal file
6
backend/apps/authentication/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.authentication'
|
||||
0
backend/apps/authentication/migrations/__init__.py
Normal file
0
backend/apps/authentication/migrations/__init__.py
Normal file
3
backend/apps/authentication/models.py
Normal file
3
backend/apps/authentication/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
backend/apps/authentication/tests.py
Normal file
3
backend/apps/authentication/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
backend/apps/authentication/urls.py
Normal file
11
backend/apps/authentication/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# authentication/urls.py - Authentication URL patterns
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'authentication'
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.login_view, name='login'),
|
||||
path('logout/', views.logout_view, name='logout'),
|
||||
path('check/', views.check_auth_view, name='check'),
|
||||
]
|
||||
127
backend/apps/authentication/views.py
Normal file
127
backend/apps/authentication/views.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# authentication/views.py - Authentication API views
|
||||
import hashlib
|
||||
from django.contrib.auth import login, logout
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from apps.core.models import SystemSettings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
"""Extract client IP address from request"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@ratelimit(key='ip', rate='10/min', method='POST', block=True)
|
||||
def login_view(request):
|
||||
"""
|
||||
Authenticate user with system password.
|
||||
Rate limited to prevent brute force attacks.
|
||||
"""
|
||||
try:
|
||||
password = request.data.get('password')
|
||||
|
||||
if not password:
|
||||
return Response(
|
||||
{'error': 'Password is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
settings_obj = SystemSettings.get_settings()
|
||||
|
||||
if settings_obj.check_password(password):
|
||||
# Create or get session
|
||||
if not request.session.session_key:
|
||||
request.session.create()
|
||||
|
||||
# Store authentication in session
|
||||
request.session['authenticated'] = True
|
||||
request.session['auth_timestamp'] = timezone.now().isoformat()
|
||||
request.session['client_ip'] = get_client_ip(request)
|
||||
request.session.save()
|
||||
|
||||
logger.info(f"Successful login from IP: {get_client_ip(request)}")
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Authentication successful',
|
||||
'session_key': request.session.session_key
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Failed login attempt from IP: {get_client_ip(request)}")
|
||||
return Response(
|
||||
{'error': 'Invalid password'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login failed: {e}")
|
||||
return Response(
|
||||
{'error': 'Authentication failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
def logout_view(request):
|
||||
"""Clear user session and log out"""
|
||||
try:
|
||||
request.session.flush()
|
||||
logger.info("User logged out successfully")
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Logged out successfully'
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Logout failed: {e}")
|
||||
return Response(
|
||||
{'error': 'Logout failed'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
def check_auth_view(request):
|
||||
"""Check if user is authenticated"""
|
||||
is_authenticated = request.session.get('authenticated', False)
|
||||
|
||||
if is_authenticated:
|
||||
# Optional: Check session age for additional security
|
||||
auth_timestamp = request.session.get('auth_timestamp')
|
||||
if auth_timestamp:
|
||||
try:
|
||||
auth_time = timezone.datetime.fromisoformat(auth_timestamp)
|
||||
if timezone.now() - auth_time > timedelta(hours=24):
|
||||
request.session.flush()
|
||||
is_authenticated = False
|
||||
except (ValueError, TypeError):
|
||||
request.session.flush()
|
||||
is_authenticated = False
|
||||
|
||||
return Response({
|
||||
'authenticated': is_authenticated,
|
||||
'session_key': request.session.session_key if is_authenticated else None
|
||||
})
|
||||
0
backend/apps/core/__init__.py
Normal file
0
backend/apps/core/__init__.py
Normal file
180
backend/apps/core/admin.py
Normal file
180
backend/apps/core/admin.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# apps/core/admin.py - Админка для управления системными настройками
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import SystemSettings, RoomActivityLog
|
||||
|
||||
|
||||
class SystemSettingsForm(forms.ModelForm):
|
||||
"""Кастомная форма для системных настроек"""
|
||||
new_password = forms.CharField(
|
||||
widget=forms.PasswordInput,
|
||||
required=False,
|
||||
label='Новый пароль',
|
||||
help_text='Введите новый пароль для входа в систему (минимум 6 символов)'
|
||||
)
|
||||
confirm_password = forms.CharField(
|
||||
widget=forms.PasswordInput,
|
||||
required=False,
|
||||
label='Подтвердите пароль',
|
||||
help_text='Повторите новый пароль'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = SystemSettings
|
||||
fields = ['is_active']
|
||||
|
||||
def clean(self):
|
||||
"""Валидация формы"""
|
||||
cleaned_data = super().clean()
|
||||
new_password = cleaned_data.get('new_password')
|
||||
confirm_password = cleaned_data.get('confirm_password')
|
||||
|
||||
if new_password or confirm_password:
|
||||
if not new_password:
|
||||
raise ValidationError('Введите новый пароль')
|
||||
|
||||
if not confirm_password:
|
||||
raise ValidationError('Подтвердите пароль')
|
||||
|
||||
if new_password != confirm_password:
|
||||
raise ValidationError('Пароли не совпадают')
|
||||
|
||||
if len(new_password) < 6:
|
||||
raise ValidationError('Пароль должен содержать минимум 6 символов')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
@admin.register(SystemSettings)
|
||||
class SystemSettingsAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Админ-интерфейс для управления системными настройками
|
||||
"""
|
||||
form = SystemSettingsForm
|
||||
list_display = ('id', 'is_active', 'created_at', 'updated_at', 'password_status')
|
||||
list_filter = ('is_active', 'created_at', 'updated_at')
|
||||
readonly_fields = ('created_at', 'updated_at', 'password_hash_preview')
|
||||
|
||||
fieldsets = (
|
||||
('Основные настройки', {
|
||||
'fields': ('is_active',)
|
||||
}),
|
||||
('Безопасность', {
|
||||
'fields': ('new_password', 'confirm_password', 'password_hash_preview'),
|
||||
'description': 'Установите новый пароль для доступа к системе'
|
||||
}),
|
||||
('Информация', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
})
|
||||
)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Обработка сохранения с проверкой пароля"""
|
||||
new_password = form.cleaned_data.get('new_password')
|
||||
|
||||
if new_password:
|
||||
obj.set_password(new_password)
|
||||
self.message_user(
|
||||
request,
|
||||
'Пароль успешно изменен',
|
||||
level=messages.SUCCESS
|
||||
)
|
||||
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def password_status(self, obj):
|
||||
"""Статус пароля"""
|
||||
if obj.access_password_hash:
|
||||
return format_html(
|
||||
'<span style="color: green;">✓ Установлен</span>'
|
||||
)
|
||||
return format_html(
|
||||
'<span style="color: red;">✗ Не установлен</span>'
|
||||
)
|
||||
password_status.short_description = 'Статус пароля'
|
||||
|
||||
def password_hash_preview(self, obj):
|
||||
"""Превью хеша пароля"""
|
||||
if obj.access_password_hash:
|
||||
hash_preview = obj.access_password_hash[:20] + '...' if len(obj.access_password_hash) > 20 else obj.access_password_hash
|
||||
return format_html(
|
||||
'<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{}</code>',
|
||||
hash_preview
|
||||
)
|
||||
return 'Пароль не установлен'
|
||||
password_hash_preview.short_description = 'Хеш пароля'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Ограничиваем создание - должен быть только один экземпляр настроек"""
|
||||
if SystemSettings.objects.exists():
|
||||
return False
|
||||
return True
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Запрещаем удаление системных настроек"""
|
||||
return False
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Возвращаем queryset"""
|
||||
return super().get_queryset(request)
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('admin/css/custom_admin.css',)
|
||||
}
|
||||
|
||||
|
||||
@admin.register(RoomActivityLog)
|
||||
class RoomActivityLogAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Админ-интерфейс для просмотра логов активности комнат
|
||||
"""
|
||||
list_display = ('room_id_short', 'action', 'participant_count', 'timestamp', 'ip_address')
|
||||
list_filter = ('action', 'timestamp')
|
||||
search_fields = ('room_id',)
|
||||
readonly_fields = ('room_id', 'action', 'timestamp', 'participant_count', 'ip_address', 'user_agent_hash')
|
||||
date_hierarchy = 'timestamp'
|
||||
ordering = ('-timestamp',)
|
||||
|
||||
def room_id_short(self, obj):
|
||||
"""Короткое отображение ID комнаты"""
|
||||
if obj.room_id:
|
||||
return f"{obj.room_id[:8]}..."
|
||||
return 'N/A'
|
||||
room_id_short.short_description = 'Room ID'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Запрещаем ручное добавление логов"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Запрещаем изменение логов"""
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Разрешаем удаление только суперпользователям"""
|
||||
return request.user.is_superuser
|
||||
|
||||
# Дополнительные действия
|
||||
actions = ['delete_old_logs']
|
||||
|
||||
def delete_old_logs(self, request, queryset):
|
||||
"""Удаление старых логов"""
|
||||
from datetime import datetime, timedelta
|
||||
old_date = datetime.now() - timedelta(days=30)
|
||||
count = RoomActivityLog.objects.filter(timestamp__lt=old_date).delete()[0]
|
||||
self.message_user(request, f'Удалено {count} старых записей (старше 30 дней)')
|
||||
delete_old_logs.short_description = 'Удалить логи старше 30 дней'
|
||||
|
||||
|
||||
# Кастомизация админки
|
||||
admin.site.site_header = 'Video Call Administration'
|
||||
admin.site.site_title = 'Video Call Admin'
|
||||
admin.site.index_title = 'Управление системой видеозвонков'
|
||||
6
backend/apps/core/apps.py
Normal file
6
backend/apps/core/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.core'
|
||||
44
backend/apps/core/migrations/0001_initial.py
Normal file
44
backend/apps/core/migrations/0001_initial.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-01 07:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SystemSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('access_password_hash', models.CharField(help_text='Hashed password for system access', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'System Settings',
|
||||
'verbose_name_plural': 'System Settings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RoomActivityLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('room_id', models.CharField(db_index=True, max_length=36)),
|
||||
('action', models.CharField(choices=[('created', 'Room Created'), ('joined', 'User Joined'), ('left', 'User Left'), ('expired', 'Room Expired'), ('deleted', 'Room Deleted')], max_length=20)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('participant_count', models.PositiveIntegerField(default=0)),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent_hash', models.CharField(blank=True, help_text='Hashed user agent for basic analytics', max_length=64, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['room_id', '-timestamp'], name='core_roomac_room_id_3e3535_idx'), models.Index(fields=['action', '-timestamp'], name='core_roomac_action_9fbe2f_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.5 on 2025-09-01 08:30
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='roomactivitylog',
|
||||
options={'ordering': ['-timestamp'], 'verbose_name': 'Room Activity Log', 'verbose_name_plural': 'Room Activity Logs'},
|
||||
),
|
||||
]
|
||||
0
backend/apps/core/migrations/__init__.py
Normal file
0
backend/apps/core/migrations/__init__.py
Normal file
92
backend/apps/core/models.py
Normal file
92
backend/apps/core/models.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# core/models.py - Core system models for video call application
|
||||
from django.db import models
|
||||
from django.contrib.auth.hashers import make_password, check_password
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class SystemSettings(models.Model):
|
||||
"""
|
||||
Model for storing system-wide settings like access password.
|
||||
Follows singleton pattern - only one instance should exist.
|
||||
"""
|
||||
access_password_hash = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Hashed password for system access"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "System Settings"
|
||||
verbose_name_plural = "System Settings"
|
||||
|
||||
def set_password(self, raw_password):
|
||||
"""Set and hash the access password"""
|
||||
self.access_password_hash = make_password(raw_password)
|
||||
|
||||
def check_password(self, raw_password):
|
||||
"""Check if provided password matches stored hash"""
|
||||
return check_password(raw_password, self.access_password_hash)
|
||||
|
||||
@classmethod
|
||||
def get_settings(cls):
|
||||
"""Get or create system settings instance"""
|
||||
settings, created = cls.objects.get_or_create(
|
||||
pk=1,
|
||||
defaults={
|
||||
'access_password_hash': make_password('admin123'),
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
return settings
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Ensure only one settings instance exists"""
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Prevent deletion of system settings"""
|
||||
pass
|
||||
|
||||
def __str__(self):
|
||||
return f"System Settings (Updated: {self.updated_at.strftime('%Y-%m-%d %H:%M')})"
|
||||
|
||||
|
||||
class RoomActivityLog(models.Model):
|
||||
"""
|
||||
Model for logging room activities without storing personal data.
|
||||
Used for analytics and system monitoring.
|
||||
"""
|
||||
ACTION_CHOICES = [
|
||||
('created', 'Room Created'),
|
||||
('joined', 'User Joined'),
|
||||
('left', 'User Left'),
|
||||
('expired', 'Room Expired'),
|
||||
('deleted', 'Room Deleted'),
|
||||
]
|
||||
|
||||
room_id = models.CharField(max_length=36, db_index=True) # UUID
|
||||
action = models.CharField(max_length=20, choices=ACTION_CHOICES)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
participant_count = models.PositiveIntegerField(default=0)
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent_hash = models.CharField(
|
||||
max_length=64,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Hashed user agent for basic analytics"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['room_id', '-timestamp']),
|
||||
models.Index(fields=['action', '-timestamp']),
|
||||
]
|
||||
verbose_name = "Room Activity Log"
|
||||
verbose_name_plural = "Room Activity Logs"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_action_display()} - {self.room_id[:8]}... at {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
3
backend/apps/core/tests.py
Normal file
3
backend/apps/core/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
backend/apps/core/urls.py
Normal file
10
backend/apps/core/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# core/urls.py - Core application URL patterns
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'core'
|
||||
|
||||
urlpatterns = [
|
||||
path('health/', views.health_check, name='health'),
|
||||
path('csrf/', views.get_csrf_token, name='csrf'),
|
||||
]
|
||||
243
backend/apps/core/views.py
Normal file
243
backend/apps/core/views.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# apps/core/views.py - Core application views including health check
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||
from django.utils import timezone
|
||||
from django.db import connection
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.middleware.csrf import get_token
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@csrf_exempt
|
||||
def health_check(request):
|
||||
"""
|
||||
Health check endpoint for monitoring system status.
|
||||
Returns detailed health information about various components.
|
||||
"""
|
||||
try:
|
||||
health_data = {
|
||||
'status': 'healthy',
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'version': getattr(settings, 'VERSION', '1.0.0'),
|
||||
'environment': 'production' if not settings.DEBUG else 'development',
|
||||
'checks': {}
|
||||
}
|
||||
|
||||
overall_status = True
|
||||
|
||||
# Database health check
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
health_data['checks']['database'] = {
|
||||
'status': 'healthy',
|
||||
'message': 'Database connection successful'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Database health check failed: {e}")
|
||||
health_data['checks']['database'] = {
|
||||
'status': 'unhealthy',
|
||||
'message': f'Database connection failed: {str(e)}'
|
||||
}
|
||||
overall_status = False
|
||||
|
||||
# Redis/Cache health check
|
||||
try:
|
||||
cache.set('health_check', 'test', 30)
|
||||
cached_value = cache.get('health_check')
|
||||
if cached_value == 'test':
|
||||
health_data['checks']['cache'] = {
|
||||
'status': 'healthy',
|
||||
'message': 'Cache (Redis) connection successful'
|
||||
}
|
||||
else:
|
||||
raise Exception("Cache test failed")
|
||||
except Exception as e:
|
||||
logger.error(f"Cache health check failed: {e}")
|
||||
health_data['checks']['cache'] = {
|
||||
'status': 'unhealthy',
|
||||
'message': f'Cache connection failed: {str(e)}'
|
||||
}
|
||||
overall_status = False
|
||||
|
||||
# System resources check
|
||||
try:
|
||||
import psutil
|
||||
memory_usage = psutil.virtual_memory().percent
|
||||
cpu_usage = psutil.cpu_percent(interval=1)
|
||||
disk_usage = psutil.disk_usage('/').percent
|
||||
|
||||
health_data['checks']['system'] = {
|
||||
'status': 'healthy' if memory_usage < 90 and cpu_usage < 90 and disk_usage < 90 else 'warning',
|
||||
'memory_usage': f"{memory_usage}%",
|
||||
'cpu_usage': f"{cpu_usage}%",
|
||||
'disk_usage': f"{disk_usage}%"
|
||||
}
|
||||
|
||||
if memory_usage > 95 or cpu_usage > 95 or disk_usage > 95:
|
||||
overall_status = False
|
||||
except ImportError:
|
||||
health_data['checks']['system'] = {
|
||||
'status': 'unavailable',
|
||||
'message': 'psutil not installed - system metrics unavailable'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"System health check failed: {e}")
|
||||
health_data['checks']['system'] = {
|
||||
'status': 'unhealthy',
|
||||
'message': f'System check failed: {str(e)}'
|
||||
}
|
||||
|
||||
# WebSocket/Channels check
|
||||
try:
|
||||
from channels.layers import get_channel_layer
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer:
|
||||
health_data['checks']['websocket'] = {
|
||||
'status': 'healthy',
|
||||
'message': 'WebSocket layer available'
|
||||
}
|
||||
else:
|
||||
raise Exception("Channel layer not configured")
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket health check failed: {e}")
|
||||
health_data['checks']['websocket'] = {
|
||||
'status': 'unhealthy',
|
||||
'message': f'WebSocket check failed: {str(e)}'
|
||||
}
|
||||
overall_status = False
|
||||
|
||||
# Update overall status
|
||||
health_data['status'] = 'healthy' if overall_status else 'unhealthy'
|
||||
|
||||
# Return appropriate HTTP status code
|
||||
status_code = 200 if overall_status else 503
|
||||
|
||||
return JsonResponse(health_data, status=status_code)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Health check endpoint failed: {e}")
|
||||
return JsonResponse({
|
||||
'status': 'error',
|
||||
'message': 'Health check failed',
|
||||
'error': str(e),
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@csrf_exempt
|
||||
def system_info(request):
|
||||
"""
|
||||
System information endpoint for monitoring and debugging.
|
||||
Only available in DEBUG mode or to authenticated users.
|
||||
"""
|
||||
# Security check - only allow in development or for authenticated requests
|
||||
if not settings.DEBUG and not request.session.get('authenticated'):
|
||||
return JsonResponse({'error': 'Access denied'}, status=403)
|
||||
|
||||
try:
|
||||
system_data = {
|
||||
'python_version': sys.version,
|
||||
'django_version': __import__('django').get_version(),
|
||||
'debug_mode': settings.DEBUG,
|
||||
'timezone': str(timezone.get_current_timezone()),
|
||||
'database_engine': settings.DATABASES['default']['ENGINE'],
|
||||
'installed_apps': list(settings.INSTALLED_APPS),
|
||||
'middleware': list(settings.MIDDLEWARE),
|
||||
}
|
||||
|
||||
# Add more detailed info in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
system_data.update({
|
||||
'secret_key_length': len(settings.SECRET_KEY),
|
||||
'allowed_hosts': settings.ALLOWED_HOSTS,
|
||||
'static_url': settings.STATIC_URL,
|
||||
'media_url': settings.MEDIA_URL,
|
||||
})
|
||||
|
||||
return JsonResponse(system_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"System info endpoint failed: {e}")
|
||||
return JsonResponse({
|
||||
'error': 'Failed to retrieve system information',
|
||||
'message': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@csrf_exempt
|
||||
def metrics(request):
|
||||
"""
|
||||
Basic metrics endpoint for monitoring.
|
||||
"""
|
||||
try:
|
||||
from apps.core.models import RoomActivityLog
|
||||
from datetime import timedelta
|
||||
|
||||
# Calculate basic metrics
|
||||
now = timezone.now()
|
||||
last_hour = now - timedelta(hours=1)
|
||||
last_day = now - timedelta(days=1)
|
||||
|
||||
metrics_data = {
|
||||
'timestamp': now.isoformat(),
|
||||
'rooms': {
|
||||
'created_last_hour': RoomActivityLog.objects.filter(
|
||||
action='created',
|
||||
timestamp__gte=last_hour
|
||||
).count(),
|
||||
'created_last_day': RoomActivityLog.objects.filter(
|
||||
action='created',
|
||||
timestamp__gte=last_day
|
||||
).count(),
|
||||
'total_created': RoomActivityLog.objects.filter(
|
||||
action='created'
|
||||
).count(),
|
||||
},
|
||||
'users': {
|
||||
'joined_last_hour': RoomActivityLog.objects.filter(
|
||||
action='joined',
|
||||
timestamp__gte=last_hour
|
||||
).count(),
|
||||
'joined_last_day': RoomActivityLog.objects.filter(
|
||||
action='joined',
|
||||
timestamp__gte=last_day
|
||||
).count(),
|
||||
}
|
||||
}
|
||||
|
||||
return JsonResponse(metrics_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Metrics endpoint failed: {e}")
|
||||
return JsonResponse({
|
||||
'error': 'Failed to retrieve metrics',
|
||||
'message': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@require_http_methods(["GET"])
|
||||
def get_csrf_token(request):
|
||||
"""Get CSRF token for API requests"""
|
||||
token = get_token(request)
|
||||
return JsonResponse({'csrfToken': token})
|
||||
|
||||
|
||||
def get_client_ip(request):
|
||||
"""Extract client IP address from request"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
0
backend/apps/rooms/__init__.py
Normal file
0
backend/apps/rooms/__init__.py
Normal file
3
backend/apps/rooms/admin.py
Normal file
3
backend/apps/rooms/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/apps/rooms/apps.py
Normal file
6
backend/apps/rooms/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RoomsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.rooms'
|
||||
319
backend/apps/rooms/consumers.py
Normal file
319
backend/apps/rooms/consumers.py
Normal file
@@ -0,0 +1,319 @@
|
||||
# rooms/consumers.py - WebSocket consumer for WebRTC signaling
|
||||
import json
|
||||
import logging
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from django.utils import timezone
|
||||
from apps.rooms.models import RoomManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoCallConsumer(AsyncWebsocketConsumer):
|
||||
"""
|
||||
WebSocket consumer for handling WebRTC signaling and room management.
|
||||
Implements secure peer-to-peer connection establishment.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.room_id = None
|
||||
self.room_group_name = None
|
||||
self.participant_id = None
|
||||
|
||||
async def connect(self):
|
||||
"""Handle WebSocket connection"""
|
||||
try:
|
||||
# Extract room ID from URL
|
||||
self.room_id = self.scope['url_route']['kwargs']['room_id']
|
||||
self.room_group_name = f'room_{self.room_id}'
|
||||
|
||||
# Get participant ID from session or generate one
|
||||
session = self.scope.get('session', {})
|
||||
self.participant_id = session.get('session_key') or f'temp_{timezone.now().timestamp()}'
|
||||
|
||||
# Verify room exists and user can join
|
||||
room_data = await self.get_room_data(self.room_id)
|
||||
|
||||
if not room_data:
|
||||
await self.close(code=4004) # Room not found
|
||||
return
|
||||
|
||||
# Check room capacity
|
||||
participants = room_data.get('participants', [])
|
||||
max_participants = room_data.get('max_participants', 2)
|
||||
|
||||
if len(participants) >= max_participants and self.participant_id not in participants:
|
||||
await self.close(code=4003) # Room is full
|
||||
return
|
||||
|
||||
# Join the room group
|
||||
await self.channel_layer.group_add(
|
||||
self.room_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Accept the WebSocket connection
|
||||
await self.accept()
|
||||
|
||||
# Notify other participants about new user
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_joined',
|
||||
'participant_id': self.participant_id,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"User {self.participant_id} connected to room {self.room_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket connection error: {e}")
|
||||
await self.close(code=4000)
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
"""Handle WebSocket disconnection"""
|
||||
try:
|
||||
if self.room_group_name and self.participant_id:
|
||||
# Notify other participants about user leaving
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'user_left',
|
||||
'participant_id': self.participant_id,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
# Remove user from room group
|
||||
await self.channel_layer.group_discard(
|
||||
self.room_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Update room data
|
||||
await self.leave_room(self.room_id, self.participant_id)
|
||||
|
||||
logger.info(f"User {self.participant_id} disconnected from room {self.room_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket disconnect error: {e}")
|
||||
|
||||
async def receive(self, text_data):
|
||||
"""Handle incoming WebSocket messages"""
|
||||
try:
|
||||
data = json.loads(text_data)
|
||||
message_type = data.get('type')
|
||||
|
||||
# Validate message structure
|
||||
if not message_type:
|
||||
await self.send_error('Message type is required')
|
||||
return
|
||||
|
||||
# Handle different message types
|
||||
if message_type == 'offer':
|
||||
await self.handle_webrtc_offer(data)
|
||||
elif message_type == 'answer':
|
||||
await self.handle_webrtc_answer(data)
|
||||
elif message_type == 'ice_candidate':
|
||||
await self.handle_ice_candidate(data)
|
||||
elif message_type == 'ping':
|
||||
await self.handle_ping()
|
||||
elif message_type == 'media_state':
|
||||
await self.handle_media_state(data)
|
||||
else:
|
||||
await self.send_error(f'Unknown message type: {message_type}')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await self.send_error('Invalid JSON format')
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket receive error: {e}")
|
||||
await self.send_error('Message processing failed')
|
||||
|
||||
async def handle_webrtc_offer(self, data):
|
||||
"""Handle WebRTC offer from peer"""
|
||||
try:
|
||||
target_participant = data.get('target')
|
||||
offer = data.get('offer')
|
||||
|
||||
if not offer:
|
||||
await self.send_error('Offer data is required')
|
||||
return
|
||||
|
||||
# Forward offer to target participant or broadcast to room
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'webrtc_offer',
|
||||
'offer': offer,
|
||||
'sender': self.participant_id,
|
||||
'target': target_participant,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebRTC offer handling error: {e}")
|
||||
await self.send_error('Failed to process offer')
|
||||
|
||||
async def handle_webrtc_answer(self, data):
|
||||
"""Handle WebRTC answer from peer"""
|
||||
try:
|
||||
target_participant = data.get('target')
|
||||
answer = data.get('answer')
|
||||
|
||||
if not answer:
|
||||
await self.send_error('Answer data is required')
|
||||
return
|
||||
|
||||
# Forward answer to target participant
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'webrtc_answer',
|
||||
'answer': answer,
|
||||
'sender': self.participant_id,
|
||||
'target': target_participant,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebRTC answer handling error: {e}")
|
||||
await self.send_error('Failed to process answer')
|
||||
|
||||
async def handle_ice_candidate(self, data):
|
||||
"""Handle ICE candidate exchange"""
|
||||
try:
|
||||
target_participant = data.get('target')
|
||||
candidate = data.get('candidate')
|
||||
|
||||
if not candidate:
|
||||
await self.send_error('ICE candidate data is required')
|
||||
return
|
||||
|
||||
# Forward ICE candidate to target participant
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'ice_candidate',
|
||||
'candidate': candidate,
|
||||
'sender': self.participant_id,
|
||||
'target': target_participant,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"ICE candidate handling error: {e}")
|
||||
await self.send_error('Failed to process ICE candidate')
|
||||
|
||||
async def handle_ping(self):
|
||||
"""Handle ping message for connection health check"""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'pong',
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}))
|
||||
|
||||
async def handle_media_state(self, data):
|
||||
"""Handle media state changes (mute/unmute, video on/off)"""
|
||||
try:
|
||||
media_state = data.get('state', {})
|
||||
|
||||
# Broadcast media state to other participants
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
{
|
||||
'type': 'media_state_update',
|
||||
'participant_id': self.participant_id,
|
||||
'state': media_state,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Media state handling error: {e}")
|
||||
await self.send_error('Failed to process media state')
|
||||
|
||||
# Group message handlers
|
||||
async def user_joined(self, event):
|
||||
"""Send user joined notification"""
|
||||
if event['participant_id'] != self.participant_id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_joined',
|
||||
'participant_id': event['participant_id'],
|
||||
'timestamp': event['timestamp']
|
||||
}))
|
||||
|
||||
async def user_left(self, event):
|
||||
"""Send user left notification"""
|
||||
if event['participant_id'] != self.participant_id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'user_left',
|
||||
'participant_id': event['participant_id'],
|
||||
'timestamp': event['timestamp']
|
||||
}))
|
||||
|
||||
async def webrtc_offer(self, event):
|
||||
"""Forward WebRTC offer to client"""
|
||||
# Only send to target participant or broadcast if no target specified
|
||||
if not event.get('target') or event['target'] == self.participant_id:
|
||||
if event['sender'] != self.participant_id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'webrtc_offer',
|
||||
'offer': event['offer'],
|
||||
'sender': event['sender'],
|
||||
'timestamp': event['timestamp']
|
||||
}))
|
||||
|
||||
async def webrtc_answer(self, event):
|
||||
"""Forward WebRTC answer to client"""
|
||||
if event.get('target') == self.participant_id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'webrtc_answer',
|
||||
'answer': event['answer'],
|
||||
'sender': event['sender'],
|
||||
'timestamp': event['timestamp']
|
||||
}))
|
||||
|
||||
async def ice_candidate(self, event):
|
||||
"""Forward ICE candidate to client"""
|
||||
# Only send to target participant or broadcast if no target specified
|
||||
if not event.get('target') or event['target'] == self.participant_id:
|
||||
if event['sender'] != self.participant_id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'ice_candidate',
|
||||
'candidate': event['candidate'],
|
||||
'sender': event['sender'],
|
||||
'timestamp': event['timestamp']
|
||||
}))
|
||||
|
||||
async def media_state_update(self, event):
|
||||
"""Forward media state update to client"""
|
||||
if event['participant_id'] != self.participant_id:
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'media_state_update',
|
||||
'participant_id': event['participant_id'],
|
||||
'state': event['state'],
|
||||
'timestamp': event['timestamp']
|
||||
}))
|
||||
|
||||
# Helper methods
|
||||
async def send_error(self, error_message):
|
||||
"""Send error message to client"""
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'error',
|
||||
'message': error_message,
|
||||
'timestamp': timezone.now().isoformat()
|
||||
}))
|
||||
|
||||
@database_sync_to_async
|
||||
def get_room_data(self, room_id):
|
||||
"""Get room data from Redis"""
|
||||
return RoomManager.get_room_by_id(room_id)
|
||||
|
||||
@database_sync_to_async
|
||||
def leave_room(self, room_id, participant_id):
|
||||
"""Remove participant from room"""
|
||||
return RoomManager.leave_room(room_id, participant_id)
|
||||
0
backend/apps/rooms/migrations/__init__.py
Normal file
0
backend/apps/rooms/migrations/__init__.py
Normal file
223
backend/apps/rooms/models.py
Normal file
223
backend/apps/rooms/models.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# rooms/models.py - Room management models
|
||||
import uuid
|
||||
import string
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class RoomManager:
|
||||
"""
|
||||
Manager class for room operations using Redis for temporary storage.
|
||||
Implements all CRUD operations for video call rooms.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_redis_client():
|
||||
"""Get Redis client instance"""
|
||||
from django.core.cache import cache
|
||||
return cache
|
||||
|
||||
@classmethod
|
||||
def generate_short_code(cls, length=None):
|
||||
"""Generate a unique short code for room access"""
|
||||
length = length or getattr(settings, 'SHORT_CODE_LENGTH', 6)
|
||||
characters = string.ascii_uppercase + string.digits
|
||||
|
||||
# Ensure uniqueness by checking existing codes
|
||||
cache = cls._get_redis_client()
|
||||
max_attempts = 100
|
||||
|
||||
for _ in range(max_attempts):
|
||||
code = ''.join(secrets.choice(characters) for _ in range(length))
|
||||
if not cache.get(f'room_code_{code}'):
|
||||
return code
|
||||
|
||||
raise ValueError("Unable to generate unique short code")
|
||||
|
||||
@classmethod
|
||||
def create_room(cls, creator_ip=None):
|
||||
"""Create a new video call room"""
|
||||
cache = cls._get_redis_client()
|
||||
|
||||
room_data = {
|
||||
'room_id': str(uuid.uuid4()),
|
||||
'short_code': cls.generate_short_code(),
|
||||
'created_at': timezone.now().isoformat(),
|
||||
'participants': [],
|
||||
'is_active': True,
|
||||
'expires_at': (
|
||||
timezone.now() +
|
||||
timedelta(hours=getattr(settings, 'ROOM_EXPIRY_HOURS', 24))
|
||||
).isoformat(),
|
||||
'creator_ip': creator_ip,
|
||||
'max_participants': getattr(settings, 'MAX_PARTICIPANTS_PER_ROOM', 2)
|
||||
}
|
||||
|
||||
# Store room data with expiration
|
||||
cache.set(
|
||||
f'room_{room_data["room_id"]}',
|
||||
room_data,
|
||||
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
|
||||
)
|
||||
|
||||
# Create code mapping for easy lookup
|
||||
cache.set(
|
||||
f'room_code_{room_data["short_code"]}',
|
||||
room_data["room_id"],
|
||||
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
|
||||
)
|
||||
|
||||
# Log room creation
|
||||
from apps.core.models import RoomActivityLog
|
||||
RoomActivityLog.objects.create(
|
||||
room_id=room_data["room_id"],
|
||||
action='created',
|
||||
ip_address=creator_ip
|
||||
)
|
||||
|
||||
return room_data
|
||||
|
||||
@classmethod
|
||||
def get_room_by_id(cls, room_id):
|
||||
"""Retrieve room data by room ID"""
|
||||
cache = cls._get_redis_client()
|
||||
return cache.get(f'room_{room_id}')
|
||||
|
||||
@classmethod
|
||||
def get_room_by_code(cls, short_code):
|
||||
"""Retrieve room data by short code"""
|
||||
cache = cls._get_redis_client()
|
||||
room_id = cache.get(f'room_code_{short_code}')
|
||||
|
||||
if room_id:
|
||||
return cls.get_room_by_id(room_id)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def join_room(cls, room_identifier, participant_id, participant_ip=None):
|
||||
"""
|
||||
Add participant to room.
|
||||
room_identifier can be either room_id or short_code
|
||||
"""
|
||||
cache = cls._get_redis_client()
|
||||
|
||||
# Try to get room by ID first, then by code
|
||||
room_data = cls.get_room_by_id(room_identifier)
|
||||
if not room_data:
|
||||
room_data = cls.get_room_by_code(room_identifier)
|
||||
|
||||
if not room_data:
|
||||
return None, "Room not found"
|
||||
|
||||
# Check if room is active and not expired
|
||||
if not room_data.get('is_active', False):
|
||||
return None, "Room is not active"
|
||||
|
||||
expires_at = timezone.datetime.fromisoformat(
|
||||
room_data['expires_at'].replace('Z', '+00:00')
|
||||
)
|
||||
if timezone.now() > expires_at:
|
||||
cls.delete_room(room_data['room_id'])
|
||||
return None, "Room has expired"
|
||||
|
||||
# Check participant limit
|
||||
current_participants = room_data.get('participants', [])
|
||||
max_participants = room_data.get('max_participants', 2)
|
||||
|
||||
if len(current_participants) >= max_participants:
|
||||
return None, "Room is full"
|
||||
|
||||
# Add participant if not already in room
|
||||
if participant_id not in current_participants:
|
||||
current_participants.append(participant_id)
|
||||
room_data['participants'] = current_participants
|
||||
|
||||
# Update room data
|
||||
cache.set(
|
||||
f'room_{room_data["room_id"]}',
|
||||
room_data,
|
||||
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
|
||||
)
|
||||
|
||||
# Log participant join
|
||||
from apps.core.models import RoomActivityLog
|
||||
RoomActivityLog.objects.create(
|
||||
room_id=room_data["room_id"],
|
||||
action='joined',
|
||||
participant_count=len(current_participants),
|
||||
ip_address=participant_ip
|
||||
)
|
||||
|
||||
return room_data, "Successfully joined room"
|
||||
|
||||
@classmethod
|
||||
def leave_room(cls, room_id, participant_id):
|
||||
"""Remove participant from room"""
|
||||
cache = cls._get_redis_client()
|
||||
room_data = cls.get_room_by_id(room_id)
|
||||
|
||||
if not room_data:
|
||||
return False
|
||||
|
||||
participants = room_data.get('participants', [])
|
||||
if participant_id in participants:
|
||||
participants.remove(participant_id)
|
||||
room_data['participants'] = participants
|
||||
|
||||
# Update room data
|
||||
cache.set(
|
||||
f'room_{room_id}',
|
||||
room_data,
|
||||
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
|
||||
)
|
||||
|
||||
# Log participant leave
|
||||
from apps.core.models import RoomActivityLog
|
||||
RoomActivityLog.objects.create(
|
||||
room_id=room_id,
|
||||
action='left',
|
||||
participant_count=len(participants)
|
||||
)
|
||||
|
||||
# Delete room if no participants left
|
||||
if not participants:
|
||||
cls.delete_room(room_id)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def delete_room(cls, room_id):
|
||||
"""Delete room and clean up all associated data"""
|
||||
cache = cls._get_redis_client()
|
||||
room_data = cls.get_room_by_id(room_id)
|
||||
|
||||
if room_data:
|
||||
# Remove code mapping
|
||||
short_code = room_data.get('short_code')
|
||||
if short_code:
|
||||
cache.delete(f'room_code_{short_code}')
|
||||
|
||||
# Remove room data
|
||||
cache.delete(f'room_{room_id}')
|
||||
|
||||
# Log room deletion
|
||||
from apps.core.models import RoomActivityLog
|
||||
RoomActivityLog.objects.create(
|
||||
room_id=room_id,
|
||||
action='deleted'
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_rooms(cls):
|
||||
"""Clean up expired rooms (to be called by scheduled task)"""
|
||||
# This would typically be implemented as a management command
|
||||
# or scheduled task using Django-Q or Celery
|
||||
pass
|
||||
7
backend/apps/rooms/routing.py
Normal file
7
backend/apps/rooms/routing.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# rooms/routing.py - WebSocket URL routing
|
||||
from django.urls import path
|
||||
from apps.rooms import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path('ws/room/<str:room_id>/', consumers.VideoCallConsumer.as_asgi()),
|
||||
]
|
||||
3
backend/apps/rooms/tests.py
Normal file
3
backend/apps/rooms/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
backend/apps/rooms/urls.py
Normal file
14
backend/apps/rooms/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# rooms/urls.py - Room management URL patterns
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'rooms'
|
||||
|
||||
urlpatterns = [
|
||||
path('create/', views.create_room, name='create'),
|
||||
path('join/', views.join_room, name='join'),
|
||||
path('<str:room_id>/', views.get_room, name='get'),
|
||||
path('<str:room_id>/leave/', views.leave_room, name='leave'),
|
||||
path('<str:room_id>/delete/', views.delete_room, name='delete'),
|
||||
]
|
||||
|
||||
293
backend/apps/rooms/views.py
Normal file
293
backend/apps/rooms/views.py
Normal file
@@ -0,0 +1,293 @@
|
||||
# rooms/views.py - Room management API views
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django_ratelimit.decorators import ratelimit
|
||||
from apps.rooms.models import RoomManager
|
||||
from apps.core.views import get_client_ip
|
||||
import qrcode
|
||||
import io
|
||||
import base64
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def require_auth(view_func):
|
||||
"""Decorator to require authentication for views"""
|
||||
def wrapper(request, *args, **kwargs):
|
||||
if not request.session.get('authenticated'):
|
||||
logger.warning(f"Unauthenticated access attempt to {request.path}")
|
||||
return Response(
|
||||
{'error': 'Authentication required'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
return view_func(request, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@require_auth
|
||||
@ratelimit(key='ip', rate='30/min', method='POST', block=True)
|
||||
def create_room(request):
|
||||
"""Create a new video call room"""
|
||||
try:
|
||||
client_ip = get_client_ip(request)
|
||||
logger.info(f"Creating room for IP: {client_ip}")
|
||||
|
||||
room_data = RoomManager.create_room(creator_ip=client_ip)
|
||||
|
||||
# Generate QR code for the room
|
||||
room_url = f"{request.build_absolute_uri('/')}join/{room_data['short_code']}"
|
||||
|
||||
qr = qrcode.QRCode(version=1, box_size=10, border=5)
|
||||
qr.add_data(room_url)
|
||||
qr.make(fit=True)
|
||||
|
||||
qr_image = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = io.BytesIO()
|
||||
qr_image.save(buffer, format='PNG')
|
||||
qr_code_data = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
response_data = {
|
||||
'room_id': room_data['room_id'],
|
||||
'short_code': room_data['short_code'],
|
||||
'room_url': room_url,
|
||||
'qr_code': f"data:image/png;base64,{qr_code_data}",
|
||||
'expires_at': room_data['expires_at'],
|
||||
'max_participants': room_data['max_participants']
|
||||
}
|
||||
|
||||
logger.info(f"Room created successfully: {room_data['room_id']}")
|
||||
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Room creation failed: {e}")
|
||||
return Response(
|
||||
{'error': 'Failed to create room'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@require_auth
|
||||
def get_room(request, room_id):
|
||||
"""Get room information by room ID"""
|
||||
try:
|
||||
logger.info(f"Getting room info for: {room_id}")
|
||||
room_data = RoomManager.get_room_by_id(room_id)
|
||||
|
||||
if not room_data:
|
||||
logger.warning(f"Room not found: {room_id}")
|
||||
return Response(
|
||||
{'error': 'Room not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Check if room has expired
|
||||
expires_at = timezone.datetime.fromisoformat(
|
||||
room_data['expires_at'].replace('Z', '+00:00')
|
||||
)
|
||||
if timezone.now() > expires_at:
|
||||
logger.info(f"Room expired, deleting: {room_id}")
|
||||
RoomManager.delete_room(room_id)
|
||||
return Response(
|
||||
{'error': 'Room has expired'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
response_data = {
|
||||
'room_id': room_data['room_id'],
|
||||
'short_code': room_data['short_code'],
|
||||
'is_active': room_data['is_active'],
|
||||
'participant_count': len(room_data.get('participants', [])),
|
||||
'max_participants': room_data.get('max_participants', 2),
|
||||
'expires_at': room_data['expires_at']
|
||||
}
|
||||
|
||||
logger.info(f"Room info retrieved: {room_id}, participants: {len(room_data.get('participants', []))}")
|
||||
return Response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to retrieve room {room_id}: {e}")
|
||||
return Response(
|
||||
{'error': 'Failed to retrieve room'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@require_auth
|
||||
@ratelimit(key='ip', rate='60/min', method='POST', block=True)
|
||||
def join_room(request):
|
||||
"""Join a room by room ID or short code"""
|
||||
try:
|
||||
room_identifier = request.data.get('room_identifier')
|
||||
|
||||
# Ensure we have a session
|
||||
if not request.session.session_key:
|
||||
request.session.create()
|
||||
|
||||
participant_id = request.session.session_key
|
||||
|
||||
if not room_identifier:
|
||||
return Response(
|
||||
{'error': 'Room identifier is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
logger.info(f"Joining room: {room_identifier} with participant: {participant_id}")
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
room_data, message = RoomManager.join_room(
|
||||
room_identifier,
|
||||
participant_id,
|
||||
client_ip
|
||||
)
|
||||
|
||||
if not room_data:
|
||||
logger.warning(f"Failed to join room: {room_identifier}, reason: {message}")
|
||||
return Response(
|
||||
{'error': message},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
response_data = {
|
||||
'success': True,
|
||||
'message': message,
|
||||
'room_id': room_data['room_id'],
|
||||
'short_code': room_data['short_code'],
|
||||
'participant_count': len(room_data.get('participants', [])),
|
||||
'participant_id': participant_id
|
||||
}
|
||||
|
||||
logger.info(f"User joined room: {room_data['room_id']}, participant: {participant_id}")
|
||||
return Response(response_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to join room: {e}")
|
||||
return Response(
|
||||
{'error': 'Failed to join room'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@require_auth
|
||||
def leave_room(request, room_id):
|
||||
"""Leave a room"""
|
||||
try:
|
||||
# Ensure we have a session
|
||||
if not request.session.session_key:
|
||||
logger.warning(f"No session key when trying to leave room: {room_id}")
|
||||
return Response(
|
||||
{'success': True, # Return success even if no session, as user wasn't in room anyway
|
||||
'message': 'No active session found'})
|
||||
|
||||
participant_id = request.session.session_key
|
||||
logger.info(f"Leaving room: {room_id} with participant: {participant_id}")
|
||||
|
||||
# Check if room exists first
|
||||
room_data = RoomManager.get_room_by_id(room_id)
|
||||
if not room_data:
|
||||
logger.info(f"Room {room_id} not found when trying to leave")
|
||||
return Response({
|
||||
'success': True, # Return success as room doesn't exist anyway
|
||||
'message': 'Room not found'
|
||||
})
|
||||
|
||||
# Check if participant is actually in the room
|
||||
participants = room_data.get('participants', [])
|
||||
if participant_id not in participants:
|
||||
logger.info(f"Participant {participant_id} not in room {room_id}")
|
||||
return Response({
|
||||
'success': True, # Return success as user wasn't in room anyway
|
||||
'message': 'Not in room'
|
||||
})
|
||||
|
||||
success = RoomManager.leave_room(room_id, participant_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"User left room: {room_id}, participant: {participant_id}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Left room successfully'
|
||||
})
|
||||
else:
|
||||
logger.warning(f"Failed to leave room: {room_id}, participant: {participant_id}")
|
||||
return Response({
|
||||
'success': True, # Still return success to avoid client errors
|
||||
'message': 'Room leave processed'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to leave room {room_id}: {e}")
|
||||
return Response(
|
||||
{'error': 'Failed to leave room'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['DELETE'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@require_auth
|
||||
def delete_room(request, room_id):
|
||||
"""Delete a room (only creator or admin can delete)"""
|
||||
try:
|
||||
logger.info(f"Deleting room: {room_id}")
|
||||
|
||||
# Check if room exists
|
||||
room_data = RoomManager.get_room_by_id(room_id)
|
||||
if not room_data:
|
||||
return Response(
|
||||
{'error': 'Room not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Additional permission check could be added here
|
||||
success = RoomManager.delete_room(room_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"Room deleted: {room_id}")
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Room deleted successfully'
|
||||
})
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Failed to delete room'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete room {room_id}: {e}")
|
||||
return Response(
|
||||
{'error': 'Failed to delete room'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
@csrf_exempt
|
||||
@require_auth
|
||||
def health_check(request):
|
||||
"""API health check endpoint"""
|
||||
return Response({
|
||||
'status': 'healthy',
|
||||
'timestamp': timezone.now().isoformat(),
|
||||
'authenticated': True,
|
||||
'session_key': request.session.session_key
|
||||
})
|
||||
22
backend/manage.py
Executable file
22
backend/manage.py
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
23
backend/requirements.txt
Normal file
23
backend/requirements.txt
Normal file
@@ -0,0 +1,23 @@
|
||||
asgiref==3.9.1
|
||||
cffi==1.17.1
|
||||
channels==4.0.0
|
||||
channels-redis==4.1.0
|
||||
cryptography==41.0.7
|
||||
Django==5.2.5
|
||||
django-cors-headers==4.3.1
|
||||
django-ratelimit==4.1.0
|
||||
django-redis==5.4.0
|
||||
djangorestframework==3.14.0
|
||||
msgpack==1.1.1
|
||||
pillow==11.3.0
|
||||
psycopg2-binary==2.9.10
|
||||
pycparser==2.22
|
||||
pypng==0.20220715.0
|
||||
python-decouple==3.8
|
||||
pytz==2025.2
|
||||
qrcode==7.4.2
|
||||
redis==5.0.1
|
||||
sqlparse==0.5.3
|
||||
typing_extensions==4.15.0
|
||||
uuid==1.30
|
||||
websockets==12.0
|
||||
72
backend/run_server.py
Normal file
72
backend/run_server.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Server startup script with WebSocket support
|
||||
Use this instead of 'python manage.py runserver' for development
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project directory to Python path
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(BASE_DIR))
|
||||
|
||||
# Set Django settings module
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
|
||||
|
||||
# Setup Django
|
||||
django.setup()
|
||||
|
||||
def check_daphne_installed():
|
||||
"""Check if Daphne is installed"""
|
||||
try:
|
||||
import daphne
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def run_with_daphne():
|
||||
"""Run server with Daphne (supports WebSocket)"""
|
||||
print("🚀 Starting server with Daphne (WebSocket support enabled)")
|
||||
print(" Backend: http://localhost:8000")
|
||||
print(" WebSocket: ws://localhost:8000/ws/")
|
||||
print(" Press Ctrl+C to stop")
|
||||
print()
|
||||
|
||||
os.system('daphne -b 0.0.0.0 -p 8000 videocall_app.asgi:application')
|
||||
|
||||
def run_with_runserver():
|
||||
"""Run server with standard runserver (no WebSocket support)"""
|
||||
print("⚠️ Running with standard Django runserver")
|
||||
print(" WebSocket connections will NOT work!")
|
||||
print(" Install Daphne for WebSocket support: pip install daphne")
|
||||
print(" Backend: http://localhost:8000")
|
||||
print(" Press Ctrl+C to stop")
|
||||
print()
|
||||
|
||||
os.system('python manage.py runserver')
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
print("🔌 Video Call Application Server")
|
||||
print("=" * 40)
|
||||
|
||||
if check_daphne_installed():
|
||||
run_with_daphne()
|
||||
else:
|
||||
print("❌ Daphne not found - WebSocket support disabled")
|
||||
print(" Install it with: pip install daphne")
|
||||
print(" Or install from requirements: pip install -r requirements.txt")
|
||||
print()
|
||||
|
||||
response = input("Continue with standard runserver? (y/N): ")
|
||||
if response.lower() == 'y':
|
||||
run_with_runserver()
|
||||
else:
|
||||
print("Please install Daphne and try again.")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
backend/videocall_app/__init__.py
Normal file
0
backend/videocall_app/__init__.py
Normal file
20
backend/videocall_app/asgi.py
Normal file
20
backend/videocall_app/asgi.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# videocall_app/asgi.py - ASGI configuration for WebSocket support
|
||||
import os
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.security.websocket import AllowedHostsOriginValidator
|
||||
from django.core.asgi import get_asgi_application
|
||||
from apps.rooms.routing import websocket_urlpatterns
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
AuthMiddlewareStack(
|
||||
URLRouter(websocket_urlpatterns)
|
||||
)
|
||||
),
|
||||
})
|
||||
303
backend/videocall_app/settings.py
Normal file
303
backend/videocall_app/settings.py
Normal file
@@ -0,0 +1,303 @@
|
||||
# videocall_app/settings.py - Django main settings configuration
|
||||
import os
|
||||
from decouple import config
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Security settings
|
||||
SECRET_KEY = config('SECRET_KEY', default='your-secret-key-here-change-in-production')
|
||||
DEBUG = config('DEBUG', default=False, cast=bool)
|
||||
|
||||
# ALLOWED_HOSTS - только ваши настоящие домены
|
||||
allowed_hosts_default = 'yourdomain.com,www.yourdomain.com,localhost,127.0.0.1'
|
||||
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default=allowed_hosts_default).split(',')
|
||||
|
||||
# Убираем пустые строки и пробелы
|
||||
ALLOWED_HOSTS = [host.strip() for host in ALLOWED_HOSTS if host.strip()]
|
||||
|
||||
# В DEBUG режиме разрешаем localhost для разработки
|
||||
if DEBUG:
|
||||
ALLOWED_HOSTS.extend(['localhost', '127.0.0.1', '0.0.0.0'])
|
||||
|
||||
# Application definition
|
||||
DJANGO_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'channels',
|
||||
'django_ratelimit',
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
'apps.core',
|
||||
'apps.rooms',
|
||||
'apps.authentication',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'videocall_app.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [os.path.join(BASE_DIR, 'templates')],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'videocall_app.wsgi.application'
|
||||
ASGI_APPLICATION = 'videocall_app.asgi.application'
|
||||
|
||||
# Database configuration
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': config('DB_NAME', default='videocall_db'),
|
||||
'USER': config('DB_USER', default='postgres'),
|
||||
'PASSWORD': config('DB_PASSWORD', default=''),
|
||||
'HOST': config('DB_HOST', default='localhost'),
|
||||
'PORT': config('DB_PORT', default='5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Redis configuration
|
||||
REDIS_URL = config('REDIS_URL', default='redis://localhost:6379/0')
|
||||
|
||||
# Channels configuration for WebSockets
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
'hosts': [REDIS_URL],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Cache configuration
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django_redis.cache.RedisCache',
|
||||
'LOCATION': REDIS_URL,
|
||||
'OPTIONS': {
|
||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Session configuration
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
SESSION_CACHE_ALIAS = 'default'
|
||||
SESSION_COOKIE_AGE = 86400 # 24 hours
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = not DEBUG # Use secure cookies in production
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
# CSRF Configuration
|
||||
CSRF_COOKIE_HTTPONLY = False # Allow JavaScript to read CSRF token
|
||||
CSRF_COOKIE_SECURE = not DEBUG # Use secure cookies in production
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
]
|
||||
|
||||
if not DEBUG:
|
||||
# Add your production domains
|
||||
CSRF_TRUSTED_ORIGINS.extend([
|
||||
'https://yourdomain.com',
|
||||
'https://www.yourdomain.com',
|
||||
])
|
||||
|
||||
# REST Framework configuration
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.AllowAny', # Changed to allow custom auth
|
||||
],
|
||||
'DEFAULT_THROTTLE_CLASSES': [
|
||||
'rest_framework.throttling.AnonRateThrottle',
|
||||
'rest_framework.throttling.UserRateThrottle'
|
||||
],
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'anon': '100/hour',
|
||||
'user': '1000/hour'
|
||||
}
|
||||
}
|
||||
|
||||
# CORS settings
|
||||
CORS_ALLOWED_ORIGINS = config(
|
||||
'CORS_ALLOWED_ORIGINS',
|
||||
default='http://localhost:3000,http://127.0.0.1:3000'
|
||||
).split(',')
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_ALL_ORIGINS = DEBUG # Only in development
|
||||
|
||||
# WebSocket origins for Channels
|
||||
ALLOWED_HOSTS_INCLUDE_WEBSOCKET = True
|
||||
|
||||
# Additional CORS headers for development
|
||||
if DEBUG:
|
||||
CORS_ALLOW_HEADERS = [
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'content-type',
|
||||
'dnt',
|
||||
'origin',
|
||||
'user-agent',
|
||||
'x-csrftoken',
|
||||
'x-requested-with',
|
||||
]
|
||||
|
||||
# Django Ratelimit settings
|
||||
RATELIMIT_USE_CACHE = 'default'
|
||||
RATELIMIT_ENABLE = True
|
||||
|
||||
# В файле backend/videocall_app/settings.py замените секцию LOGGING на:
|
||||
|
||||
# Logging configuration
|
||||
import os
|
||||
|
||||
# Создаем директорию для логов если её нет
|
||||
LOGS_DIR = BASE_DIR / 'logs'
|
||||
LOGS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '[{levelname}] {asctime} {name}: {message}',
|
||||
'style': '{',
|
||||
},
|
||||
'simple': {
|
||||
'format': '{levelname} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
'file': {
|
||||
'class': 'logging.FileHandler',
|
||||
'filename': LOGS_DIR / 'django.log',
|
||||
'formatter': 'verbose',
|
||||
} if os.access(LOGS_DIR, os.W_OK) else {
|
||||
# Fallback to console if can't write to file
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'apps.authentication': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
'apps.rooms': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
# Additional locations of static files
|
||||
STATICFILES_DIRS = []
|
||||
if (BASE_DIR / 'static').exists():
|
||||
STATICFILES_DIRS.append(BASE_DIR / 'static')
|
||||
|
||||
# Static files finders
|
||||
STATICFILES_FINDERS = [
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
]
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Application-specific settings
|
||||
ROOM_EXPIRY_HOURS = 24
|
||||
MAX_PARTICIPANTS_PER_ROOM = 2
|
||||
SHORT_CODE_LENGTH = 6
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Security settings for production
|
||||
if not DEBUG:
|
||||
# Trust proxy headers from nginx
|
||||
USE_X_FORWARDED_HOST = True
|
||||
USE_X_FORWARDED_PORT = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# ВАЖНО: НЕ ВКЛЮЧАЕМ принудительное перенаправление на HTTPS
|
||||
# так как это делает nginx
|
||||
SECURE_SSL_REDIRECT = False
|
||||
|
||||
# Остальные настройки безопасности
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_SECONDS = 31536000
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
|
||||
# Дополнительные заголовки безопасности
|
||||
SECURE_REFERRER_POLICY = 'same-origin'
|
||||
17
backend/videocall_app/urls.py
Normal file
17
backend/videocall_app/urls.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# videocall_app/urls.py - Main URL configuration
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/auth/', include('apps.authentication.urls')),
|
||||
path('api/rooms/', include('apps.rooms.urls')),
|
||||
path('api/', include('apps.core.urls')),
|
||||
]
|
||||
|
||||
# Serve media files in development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
16
backend/videocall_app/wsgi.py
Normal file
16
backend/videocall_app/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for videocall_app project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
195
docker-compose.yml
Normal file
195
docker-compose.yml
Normal file
@@ -0,0 +1,195 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-videocall_db}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-videocall_db}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
|
||||
# Redis for Sessions and Caching
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
command: redis-server --appendonly yes
|
||||
|
||||
# Инициализация статических файлов и миграций
|
||||
static-init:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- static_volume:/staticfiles
|
||||
- media_volume:/app/media
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DEBUG=False
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- POSTGRES_DB=${POSTGRES_DB:-videocall_db}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
user: "0:0" # Запускаем как root для создания директорий
|
||||
command: >
|
||||
sh -c "
|
||||
echo '🚀 Starting video call app initialization...' &&
|
||||
echo '📁 Creating directories and setting permissions...' &&
|
||||
mkdir -p /staticfiles /app/media /app/logs &&
|
||||
chown -R 1000:1000 /staticfiles /app/media /app/logs &&
|
||||
chmod -R 755 /app/logs &&
|
||||
echo '🗄️ Running database migrations...' &&
|
||||
python manage.py migrate &&
|
||||
echo '📦 Collecting static files...' &&
|
||||
python manage.py collectstatic --noinput --clear &&
|
||||
echo '🔧 Setting final permissions...' &&
|
||||
chown -R 1000:1000 /staticfiles /app/media /app/logs &&
|
||||
echo '✅ Video call app initialization complete!'
|
||||
"
|
||||
restart: "no"
|
||||
|
||||
# Django Backend with Daphne (WebSocket support)
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- static_volume:/staticfiles:ro
|
||||
- media_volume:/app/media
|
||||
- ./backend/logs:/app/logs
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DEBUG=False
|
||||
- DB_HOST=db
|
||||
- DB_PORT=5432
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- POSTGRES_DB=${POSTGRES_DB:-videocall_db}
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- DB_PASSWORD=${POSTGRES_PASSWORD}
|
||||
depends_on:
|
||||
static-init:
|
||||
condition: service_completed_successfully
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import requests; requests.get('http://localhost:8000/api/health/', timeout=10)",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
command: >
|
||||
sh -c "
|
||||
echo '🌐 Starting Django backend with Daphne (WebSocket support)...' &&
|
||||
echo '📊 Verifying configuration...' &&
|
||||
python manage.py check &&
|
||||
echo '🔌 Starting Daphne server...' &&
|
||||
daphne -b 0.0.0.0 -p 8000 videocall_app.asgi:application --access-log - --verbosity 2
|
||||
"
|
||||
|
||||
# Vue.js Frontend
|
||||
frontend:
|
||||
build:
|
||||
context: ./videocall-frontend
|
||||
dockerfile: Dockerfile
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Nginx Reverse Proxy with WebSocket support
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- static_volume:/staticfiles:ro
|
||||
- media_volume:/app/media:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
networks:
|
||||
- app-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--quiet",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost/health/",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
static_volume:
|
||||
driver: local
|
||||
media_volume:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
33
env.example
Normal file
33
env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# Django настройки
|
||||
DEBUG=False
|
||||
SECRET_KEY=your-super-secret-django-key-change-this-immediately-in-production
|
||||
|
||||
# База данных PostgreSQL
|
||||
POSTGRES_DB=videocall_db
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=your-strong-db-password-here
|
||||
DB_PASSWORD=your-strong-db-password-here
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Redis для сессий и WebSocket
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Настройки приложения
|
||||
ROOM_EXPIRY_HOURS=24
|
||||
MAX_PARTICIPANTS_PER_ROOM=2
|
||||
SHORT_CODE_LENGTH=6
|
||||
|
||||
# CORS настройки
|
||||
CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com,localhost,127.0.0.1
|
||||
|
||||
# SSL сертификаты (пути в системе)
|
||||
SSL_CERT_PATH=/etc/letsencrypt/live/yourdomain.com/fullchain.pem
|
||||
SSL_KEY_PATH=/etc/letsencrypt/live/yourdomain.com/privkey.pem
|
||||
DOMAIN_NAME=yourdomain.com
|
||||
|
||||
# Frontend настройки (для разработки)
|
||||
VITE_API_BASE_URL=https://yourdomain.com/api
|
||||
VITE_WS_BASE_URL=wss://yourdomain.com
|
||||
VITE_APP_NAME=Video Call App
|
||||
284
nginx.conf
Normal file
284
nginx.conf
Normal file
@@ -0,0 +1,284 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Basic Settings
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 50M;
|
||||
server_tokens off;
|
||||
|
||||
# Gzip Settings
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1000;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Rate Limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=ws:10m rate=5r/s;
|
||||
|
||||
# Upstream Backend
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Upstream Frontend
|
||||
upstream frontend {
|
||||
server frontend:80;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Production HTTPS Server
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
# SSL Configuration
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
|
||||
# Modern SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Security Headers - ИСПРАВЛЕНО для CSP с портами
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; connect-src 'self' https: ws: wss:; media-src 'self' blob:; img-src 'self' data: blob:;" always;
|
||||
|
||||
# WebSocket support for video calls
|
||||
location /ws/ {
|
||||
limit_req zone=ws burst=10 nodelay;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# ВАЖНЫЕ заголовки для предотвращения редиректов
|
||||
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 https;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
# WebSocket specific timeouts
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 60s;
|
||||
|
||||
# Disable buffering for WebSocket
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Static Files (Django)
|
||||
location /static/ {
|
||||
alias /staticfiles/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
|
||||
try_files $uri $uri/ =404;
|
||||
access_log off;
|
||||
|
||||
# Compression for static files
|
||||
location ~* \.(js|css)$ {
|
||||
gzip_static on;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Font files
|
||||
location ~* \.(woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
}
|
||||
|
||||
# Images
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg)$ {
|
||||
expires 1M;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
|
||||
# Media Files (Django)
|
||||
location /media/ {
|
||||
alias /app/media/;
|
||||
expires 1M;
|
||||
add_header Cache-Control "public";
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# API Routes
|
||||
location /api/ {
|
||||
limit_req zone=api burst=50 nodelay;
|
||||
|
||||
proxy_pass http://backend;
|
||||
|
||||
# ВАЖНЫЕ заголовки для предотвращения редиректов
|
||||
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 https;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
# API specific timeouts
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# Buffering
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Admin Routes - ВАЖНО для исправления редиректов
|
||||
location /admin/ {
|
||||
limit_req zone=auth burst=5 nodelay;
|
||||
|
||||
proxy_pass http://backend;
|
||||
|
||||
# КРИТИЧЕСКИ ВАЖНЫЕ заголовки для админки
|
||||
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 https;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
# Отключаем редиректы от nginx
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# Frontend Application (Vue.js SPA)
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
|
||||
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 https;
|
||||
proxy_set_header X-Forwarded-Host $server_name;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
# Handle SPA routing
|
||||
proxy_intercept_errors on;
|
||||
error_page 404 = @fallback;
|
||||
}
|
||||
|
||||
# SPA Fallback
|
||||
location @fallback {
|
||||
proxy_pass http://frontend;
|
||||
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 https;
|
||||
}
|
||||
|
||||
# Health Check
|
||||
location /health/ {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Robots.txt
|
||||
location /robots.txt {
|
||||
alias /staticfiles/robots.txt;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Favicon
|
||||
location /favicon.ico {
|
||||
alias /staticfiles/favicon.ico;
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP to HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
# Allow Let's Encrypt challenges
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# Блокировка запросов по IP и техническим доменам
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
|
||||
# Минимальная SSL конфигурация для блокировки HTTPS запросов
|
||||
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
|
||||
ssl_reject_handshake on;
|
||||
|
||||
# Возвращаем 444 (connection closed without response) для всех запросов
|
||||
return 444;
|
||||
}
|
||||
}
|
||||
8
videocall-frontend/.editorconfig
Normal file
8
videocall-frontend/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
3
videocall-frontend/.env.example
Normal file
3
videocall-frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_BASE_URL=https://yourdomain.com/api
|
||||
VITE_WS_BASE_URL=wss://yourdomain.com
|
||||
VITE_APP_NAME=Video Call App
|
||||
1
videocall-frontend/.gitattributes
vendored
Normal file
1
videocall-frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
30
videocall-frontend/.gitignore
vendored
Normal file
30
videocall-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
6
videocall-frontend/.prettierrc.json
Normal file
6
videocall-frontend/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
27
videocall-frontend/Dockerfile
Normal file
27
videocall-frontend/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копирование package.json и установка всех зависимостей
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Копирование исходного кода и сборка
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production стадия с nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Удаление дефолтной конфигурации nginx
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Копирование собранного приложения
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Копирование кастомной конфигурации nginx для SPA
|
||||
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
26
videocall-frontend/eslint.config.js
Normal file
26
videocall-frontend/eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{js,mjs,jsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/essential'],
|
||||
skipFormatting,
|
||||
])
|
||||
182
videocall-frontend/index.html
Normal file
182
videocall-frontend/index.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Video Call App</title>
|
||||
<meta name="title" content="Video Call App" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Secure video calling without registration. Create rooms, share links, and connect instantly."
|
||||
/>
|
||||
|
||||
<!-- Tailwind CSS - ВАЖНО: загружается перед основными стилями -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
// Конфигурация Tailwind для dark mode
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0fdf4',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Security Headers -->
|
||||
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin" />
|
||||
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
|
||||
<meta http-equiv="X-Frame-Options" content="DENY" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#22c55e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="VideoCall" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Video Call App" />
|
||||
<meta property="og:description" content="Secure video calling without registration" />
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<!-- Основные стили приложения -->
|
||||
<style>
|
||||
/* Базовые стили до загрузки приложения */
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background-color: #111827;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Загрузочный экран */
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.dark #loading-screen {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top: 4px solid #22c55e;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Скрыть загрузочный экран после загрузки приложения */
|
||||
.app-loaded #loading-screen {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<!-- Загрузочный экран -->
|
||||
<div id="loading-screen">
|
||||
<div class="text-center">
|
||||
<div class="spinner"></div>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-300">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<div
|
||||
style="
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<h1 style="font-size: 1.5rem; margin-bottom: 1rem">JavaScript Required</h1>
|
||||
<p style="color: #6b7280">
|
||||
This video calling application requires JavaScript to function properly.
|
||||
Please enable JavaScript in your browser settings and refresh the page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
<!-- Скрипт для скрытия загрузочного экрана -->
|
||||
<script>
|
||||
// Скрыть загрузочный экран после загрузки DOM
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => {
|
||||
document.body.classList.add('app-loaded')
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// Error Handling
|
||||
window.addEventListener('error', function (e) {
|
||||
console.error('Global error:', e.error)
|
||||
document.body.classList.add('app-loaded') // Скрыть загрузочный экран даже при ошибке
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', function (e) {
|
||||
console.error('Unhandled promise rejection:', e.reason)
|
||||
e.preventDefault()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
videocall-frontend/jsconfig.json
Normal file
8
videocall-frontend/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
45
videocall-frontend/nginx-spa.conf
Normal file
45
videocall-frontend/nginx-spa.conf
Normal file
@@ -0,0 +1,45 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Handle SPA routing - fallback to index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Ensure the SPA shell is always fresh
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||
expires 0;
|
||||
}
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
}
|
||||
|
||||
8850
videocall-frontend/package-lock.json
generated
Normal file
8850
videocall-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
videocall-frontend/package.json
Normal file
36
videocall-frontend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "videocall-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Video call application frontend",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .vue,.js,.ts --fix",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.16",
|
||||
"@vueuse/core": "^10.5.0",
|
||||
"axios": "^1.6.2",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode": "^1.5.3",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-plugin-vue": "^9.18.1",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.4",
|
||||
"vite-plugin-pwa": "^0.17.5",
|
||||
"vue-tsc": "^1.8.22",
|
||||
"workbox-window": "^7.0.0"
|
||||
}
|
||||
}
|
||||
6
videocall-frontend/postcss.config.js
Normal file
6
videocall-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
videocall-frontend/public/favicon.ico
Normal file
BIN
videocall-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
80
videocall-frontend/public/site.webmanifest
Normal file
80
videocall-frontend/public/site.webmanifest
Normal file
@@ -0,0 +1,80 @@
|
||||
|
||||
// public/site.webmanifest - PWA manifest
|
||||
{
|
||||
"name": "Video Call App",
|
||||
"short_name": "VideoCall",
|
||||
"description": "Secure video calling without registration",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#00C853",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"lang": "en",
|
||||
"categories": ["communication", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "pwa-64x64.png",
|
||||
"sizes": "64x64",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "pwa-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "pwa-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "maskable-icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "screenshot1.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Video call interface"
|
||||
},
|
||||
{
|
||||
"src": "screenshot2.png",
|
||||
"sizes": "375x812",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Mobile video call"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Create Room",
|
||||
"short_name": "Create",
|
||||
"description": "Start a new video call",
|
||||
"url": "/?action=create",
|
||||
"icons": [
|
||||
{
|
||||
"src": "shortcut-create.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Join Room",
|
||||
"short_name": "Join",
|
||||
"description": "Join an existing video call",
|
||||
"url": "/?action=join",
|
||||
"icons": [
|
||||
{
|
||||
"src": "shortcut-join.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
93
videocall-frontend/src/App.vue
Normal file
93
videocall-frontend/src/App.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/App.vue - Main application component
|
||||
<template>
|
||||
<div id="app" class="min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors">
|
||||
<router-view />
|
||||
|
||||
<!-- Global loading indicator -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="globalStore.isLoading"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-xl">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-500 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-300 text-sm">{{ globalStore.loadingMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Global notifications -->
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-40 space-y-2">
|
||||
<div
|
||||
v-for="notification in globalStore.notifications"
|
||||
:key="notification.id"
|
||||
:class="[
|
||||
'notification',
|
||||
notification.type === 'error' ? 'bg-red-500' :
|
||||
notification.type === 'success' ? 'bg-green-500' : 'bg-blue-500'
|
||||
]"
|
||||
class="text-white p-4 rounded-lg shadow-lg max-w-sm animate-slide-in"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium">{{ notification.message }}</p>
|
||||
<button
|
||||
@click="globalStore.removeNotification(notification.id)"
|
||||
class="ml-2 text-white hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useGlobalStore } from './stores/global'
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
onMounted(() => {
|
||||
// Check authentication on app load
|
||||
globalStore.checkAuthentication()
|
||||
|
||||
// Set up dark mode detection
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
globalStore.setDarkMode(mediaQuery.matches)
|
||||
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
globalStore.setDarkMode(e.matches)
|
||||
})
|
||||
|
||||
// Set up network status monitoring
|
||||
window.addEventListener('online', () => {
|
||||
globalStore.setNetworkStatus(true)
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
globalStore.setNetworkStatus(false)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notification {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
86
videocall-frontend/src/assets/base.css
Normal file
86
videocall-frontend/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
videocall-frontend/src/assets/logo.svg
Normal file
1
videocall-frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
35
videocall-frontend/src/assets/main.css
Normal file
35
videocall-frontend/src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
87
videocall-frontend/src/components/ActionCard.vue
Normal file
87
videocall-frontend/src/components/ActionCard.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
// src/components/ActionCard.vue - Reusable action card component
|
||||
<template>
|
||||
<div class="card p-6 hover:shadow-lg transition-shadow cursor-pointer" @click="handleClick">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div
|
||||
:class="[
|
||||
'w-12 h-12 rounded-full flex items-center justify-center',
|
||||
loading ? 'bg-gray-200 dark:bg-gray-700' : 'bg-green-100 dark:bg-green-900',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="animate-spin rounded-full h-6 w-6 border-b-2 border-green-500"
|
||||
></div>
|
||||
<svg
|
||||
v-else-if="icon === 'plus'"
|
||||
class="w-6 h-6 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="icon === 'login'"
|
||||
class="w-6 h-6 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">{{ title }}</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm">{{ description }}</p>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
212
videocall-frontend/src/components/Dashboard.vue
Normal file
212
videocall-frontend/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
// src/components/Dashboard.vue - Main dashboard component
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Header -->
|
||||
<header
|
||||
class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Video Call</h1>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||
<!-- Video Preview -->
|
||||
<div class="mb-8">
|
||||
<VideoPreview />
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
<ActionCard
|
||||
title="Create Link"
|
||||
description="Start a new video call and share the link"
|
||||
icon="plus"
|
||||
:loading="roomsStore.isCreatingRoom"
|
||||
@click="handleCreateRoom"
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
title="Join Call"
|
||||
description="Enter a room code or link to join"
|
||||
icon="login"
|
||||
@click="showJoinModal = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Room History -->
|
||||
<div v-if="roomsStore.roomHistory.length > 0" class="mb-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Recent Rooms</h2>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="room in roomsStore.roomHistory.slice(0, 5)"
|
||||
:key="room.room_id"
|
||||
class="card p-4 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ room.short_code }}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ utils.formatRelativeTime(new Date(room.joined_at)) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="handleJoinRoom(room.short_code)"
|
||||
:disabled="roomsStore.isJoiningRoom"
|
||||
class="btn-secondary px-4 py-2 text-sm"
|
||||
>
|
||||
Rejoin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Join Room Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showJoinModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click="showJoinModal = false"
|
||||
>
|
||||
<div class="card w-full max-w-md p-6 animate-slide-up" @click.stop>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Join Video Call</h3>
|
||||
|
||||
<form @submit.prevent="handleJoinSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Room Code or Link
|
||||
</label>
|
||||
<input
|
||||
v-model="joinInput"
|
||||
type="text"
|
||||
placeholder="Enter room code or paste link"
|
||||
class="input-field"
|
||||
:disabled="roomsStore.isJoiningRoom"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showJoinModal = false"
|
||||
class="btn-secondary flex-1"
|
||||
:disabled="roomsStore.isJoiningRoom"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!joinInput.trim() || roomsStore.isJoiningRoom"
|
||||
class="btn-primary flex-1 disabled:opacity-50"
|
||||
>
|
||||
<span v-if="roomsStore.isJoiningRoom">Joining...</span>
|
||||
<span v-else>Join</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Room Created Modal -->
|
||||
<RoomCreatedModal
|
||||
v-if="showRoomCreatedModal && createdRoom"
|
||||
:room="createdRoom"
|
||||
@close="showRoomCreatedModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGlobalStore } from '../stores/global'
|
||||
import { useRoomsStore } from '../stores/rooms'
|
||||
import { utils } from '../services/utils'
|
||||
import VideoPreview from './VideoPreview.vue'
|
||||
import ActionCard from './ActionCard.vue'
|
||||
import RoomCreatedModal from './RoomCreatedModal.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const globalStore = useGlobalStore()
|
||||
const roomsStore = useRoomsStore()
|
||||
|
||||
// Reactive state
|
||||
const showJoinModal = ref(false)
|
||||
const showRoomCreatedModal = ref(false)
|
||||
const joinInput = ref('')
|
||||
const createdRoom = ref(null)
|
||||
|
||||
// Methods
|
||||
const handleLogout = async () => {
|
||||
await globalStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
const result = await roomsStore.createRoom()
|
||||
|
||||
if (result.success) {
|
||||
createdRoom.value = result.room
|
||||
showRoomCreatedModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinRoom = async (roomIdentifier) => {
|
||||
const result = await roomsStore.joinRoom(roomIdentifier)
|
||||
|
||||
if (result.success) {
|
||||
router.push(`/call/${result.room.room_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinSubmit = async () => {
|
||||
if (!joinInput.value.trim()) return
|
||||
|
||||
// Extract room code from URL if needed
|
||||
let roomIdentifier = joinInput.value.trim()
|
||||
|
||||
// If it's a full URL, extract the room code
|
||||
if (roomIdentifier.includes('/join/')) {
|
||||
const match = roomIdentifier.match(/\/join\/([A-Z0-9]+)/)
|
||||
if (match) {
|
||||
roomIdentifier = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
showJoinModal.value = false
|
||||
await handleJoinRoom(roomIdentifier)
|
||||
joinInput.value = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
roomsStore.loadHistory()
|
||||
})
|
||||
</script>
|
||||
44
videocall-frontend/src/components/HelloWorld.vue
Normal file
44
videocall-frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
104
videocall-frontend/src/components/JoinRoom.vue
Normal file
104
videocall-frontend/src/components/JoinRoom.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||
<div class="card w-full max-w-md p-8 animate-fade-in">
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Join Video Call</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
Room code: <span class="font-mono font-bold text-lg">{{ roomCode }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isJoining" class="text-center py-8">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-300">Joining room...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-center py-8">
|
||||
<div
|
||||
class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg class="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Room Not Found</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4">{{ error }}</p>
|
||||
<button @click="$router.push('/')" class="btn-primary px-6 py-2">Back to Dashboard</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="text-center">
|
||||
<button @click="joinRoom" class="btn-primary w-full py-4 text-lg">Join Call</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
@click="$router.push('/')"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoomsStore } from '../stores/rooms'
|
||||
import { useGlobalStore } from '../stores/global'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const roomsStore = useRoomsStore()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const roomCode = ref('')
|
||||
const isJoining = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
roomCode.value = route.params.shortCode
|
||||
if (!roomCode.value) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
|
||||
const joinRoom = async () => {
|
||||
try {
|
||||
isJoining.value = true
|
||||
const result = await roomsStore.joinRoom(roomCode.value)
|
||||
|
||||
if (result.success) {
|
||||
router.push(`/call/${result.room.room_id}`)
|
||||
} else {
|
||||
error.value = result.error
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = 'Failed to join room'
|
||||
} finally {
|
||||
isJoining.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
94
videocall-frontend/src/components/LoginForm.vue
Normal file
94
videocall-frontend/src/components/LoginForm.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50 dark:from-gray-900 dark:to-gray-800 p-4"
|
||||
>
|
||||
<div class="card w-full max-w-md p-8 animate-fade-in">
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Video Call</h1>
|
||||
<p class="text-gray-600 dark:text-gray-300">Enter the access password to continue</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
class="input-field"
|
||||
:disabled="isLoading"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!password.trim() || isLoading"
|
||||
class="btn-primary w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isLoading" class="flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
Signing in...
|
||||
</span>
|
||||
<span v-else>Sign In</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Secure video calling without registration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGlobalStore } from '../stores/global'
|
||||
|
||||
const router = useRouter()
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const password = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!password.value.trim()) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const result = await globalStore.login(password.value)
|
||||
|
||||
if (result.success) {
|
||||
const redirectTo = new URLSearchParams(window.location.search).get('redirect') || '/'
|
||||
router.push(redirectTo)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
40
videocall-frontend/src/components/NotFound.vue
Normal file
40
videocall-frontend/src/components/NotFound.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
|
||||
<div class="text-center max-w-md mx-auto">
|
||||
<div
|
||||
class="w-24 h-24 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-red-500 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-6xl font-bold text-gray-900 dark:text-white mb-4">404</h1>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-4">Page Not Found</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">
|
||||
The page you're looking for doesn't exist or may have been moved.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<button @click="$router.push('/')" class="btn-primary px-8 py-3 w-full">
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<button @click="$router.go(-1)" class="btn-secondary px-8 py-3 w-full">Go Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Component logic can be added here if needed
|
||||
</script>
|
||||
136
videocall-frontend/src/components/RoomCreatedModal.vue
Normal file
136
videocall-frontend/src/components/RoomCreatedModal.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
// src/components/RoomCreatedModal.vue - Room creation success modal
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div class="card w-full max-w-lg p-6 animate-slide-up">
|
||||
<div class="text-center mb-6">
|
||||
<div
|
||||
class="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Room Created!</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mt-2">Share the link or code to invite others</p>
|
||||
</div>
|
||||
|
||||
<!-- Room Code -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>Room Code</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
:value="room.short_code"
|
||||
readonly
|
||||
class="input-field flex-1 font-mono text-lg text-center tracking-wider"
|
||||
/>
|
||||
<button @click="copyCode" class="btn-secondary px-4 py-3 min-w-[80px]">
|
||||
{{ codeCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Room Link -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>Room Link</label
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input :value="room.room_url" readonly class="input-field flex-1 text-sm" />
|
||||
<button @click="copyLink" class="btn-secondary px-4 py-3 min-w-[80px]">
|
||||
{{ linkCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="mb-6 text-center">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">QR Code</p>
|
||||
<div class="inline-block p-4 bg-white rounded-xl shadow-sm">
|
||||
<img :src="room.qr_code" alt="QR Code" class="w-32 h-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex space-x-3">
|
||||
<button @click="emit('close')" class="btn-secondary flex-1">Close</button>
|
||||
<button @click="joinRoom" class="btn-primary flex-1">Join Room</button>
|
||||
</div>
|
||||
|
||||
<!-- Room Info -->
|
||||
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Room expires in {{ formatExpiryTime(room.expires_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { utils } from '../services/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
room: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// Reactive state
|
||||
const codeCopied = ref(false)
|
||||
const linkCopied = ref(false)
|
||||
|
||||
// Methods
|
||||
const copyCode = async () => {
|
||||
const result = await utils.copyToClipboard(props.room.short_code)
|
||||
if (result.success) {
|
||||
codeCopied.value = true
|
||||
setTimeout(() => {
|
||||
codeCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
const result = await utils.copyToClipboard(props.room.room_url)
|
||||
if (result.success) {
|
||||
linkCopied.value = true
|
||||
setTimeout(() => {
|
||||
linkCopied.value = false
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = () => {
|
||||
emit('close')
|
||||
router.push(`/call/${props.room.room_id}`)
|
||||
}
|
||||
|
||||
const formatExpiryTime = (expiryDate) => {
|
||||
const expiry = new Date(expiryDate)
|
||||
const now = new Date()
|
||||
const diffHours = Math.ceil((expiry - now) / (1000 * 60 * 60))
|
||||
|
||||
if (diffHours <= 1) {
|
||||
return 'less than 1 hour'
|
||||
} else if (diffHours < 24) {
|
||||
return `${diffHours} hours`
|
||||
} else {
|
||||
const diffDays = Math.ceil(diffHours / 24)
|
||||
return `${diffDays} days`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
94
videocall-frontend/src/components/TheWelcome.vue
Normal file
94
videocall-frontend/src/components/TheWelcome.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
1007
videocall-frontend/src/components/VideoCall.vue
Normal file
1007
videocall-frontend/src/components/VideoCall.vue
Normal file
File diff suppressed because it is too large
Load Diff
612
videocall-frontend/src/components/VideoPreview.vue
Normal file
612
videocall-frontend/src/components/VideoPreview.vue
Normal file
@@ -0,0 +1,612 @@
|
||||
<!-- src/components/VideoPreview.vue - Complete local video preview component -->
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="video-container aspect-video max-w-2xl mx-auto">
|
||||
<!-- Video Element -->
|
||||
<video
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
class="w-full h-full object-cover"
|
||||
:class="{ mirror: shouldMirror }"
|
||||
></video>
|
||||
|
||||
<!-- Overlay controls (show on hover) -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-30 transition-all duration-300 flex items-center justify-center opacity-0 hover:opacity-100"
|
||||
>
|
||||
<div class="flex space-x-4">
|
||||
<!-- Video Toggle -->
|
||||
<button
|
||||
@click="toggleVideo"
|
||||
:class="[
|
||||
'control-button transform hover:scale-110 transition-transform',
|
||||
webrtcStore.isVideoEnabled ? 'control-button-active' : 'control-button-inactive',
|
||||
]"
|
||||
:title="webrtcStore.isVideoEnabled ? 'Turn off camera' : 'Turn on camera'"
|
||||
:disabled="!webrtcStore.localStream"
|
||||
>
|
||||
<svg
|
||||
v-if="webrtcStore.isVideoEnabled"
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18 21l-1.5-1.5m-6.364-6.364L8.5 14.5 7 13l1.636-1.636"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Audio Toggle -->
|
||||
<button
|
||||
@click="toggleAudio"
|
||||
:class="[
|
||||
'control-button transform hover:scale-110 transition-transform',
|
||||
webrtcStore.isAudioEnabled ? 'control-button-active' : 'control-button-inactive',
|
||||
]"
|
||||
:title="webrtcStore.isAudioEnabled ? 'Mute microphone' : 'Unmute microphone'"
|
||||
:disabled="!webrtcStore.localStream"
|
||||
>
|
||||
<svg
|
||||
v-if="webrtcStore.isAudioEnabled"
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1m0 0V7a3 3 0 013-3h8a3 3 0 013 3v2M4 9h1m11 0h5m-9 0a1 1 0 011-1v-1a1 1 0 011-1m-1 1v1a1 1 0 001 1M9 7h8a3 3 0 013 3v2"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Settings Button -->
|
||||
<button
|
||||
@click="showSettings = !showSettings"
|
||||
class="control-button control-button-inactive transform hover:scale-110 transition-transform"
|
||||
title="Video settings"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No video placeholder -->
|
||||
<div
|
||||
v-if="!webrtcStore.hasLocalVideo || !webrtcStore.isVideoEnabled"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gray-800"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-20 h-20 bg-gray-600 rounded-full flex items-center justify-center mx-auto mb-4 animate-pulse-slow"
|
||||
>
|
||||
<svg
|
||||
class="w-10 h-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-gray-400 text-lg font-medium">
|
||||
{{ !webrtcStore.hasLocalVideo ? 'No camera detected' : 'Camera is off' }}
|
||||
</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{{
|
||||
!webrtcStore.hasLocalVideo
|
||||
? 'Check your camera connection'
|
||||
: 'Click the camera button to turn on'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div
|
||||
v-if="isInitializing"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50"
|
||||
>
|
||||
<div class="text-center text-white">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-lg font-medium">{{ loadingMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video quality indicator -->
|
||||
<div
|
||||
v-if="webrtcStore.hasLocalVideo && showQualityIndicator"
|
||||
class="absolute top-4 right-4 flex items-center space-x-2 bg-black bg-opacity-50 px-3 py-2 rounded-lg text-white text-sm"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-3 h-3 rounded-full',
|
||||
videoQuality === 'high'
|
||||
? 'bg-green-400'
|
||||
: videoQuality === 'medium'
|
||||
? 'bg-yellow-400'
|
||||
: 'bg-red-400',
|
||||
]"
|
||||
></div>
|
||||
<span>{{ videoQualityText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media access error -->
|
||||
<div
|
||||
v-if="mediaError"
|
||||
class="mt-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4 animate-fade-in"
|
||||
>
|
||||
<div class="flex">
|
||||
<svg
|
||||
class="w-5 h-5 text-yellow-400 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-300 font-medium">
|
||||
Camera access needed
|
||||
</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">{{ mediaError }}</p>
|
||||
<div class="mt-3 flex space-x-3">
|
||||
<button
|
||||
@click="initializeMedia"
|
||||
class="text-sm bg-yellow-100 hover:bg-yellow-200 dark:bg-yellow-800 dark:hover:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-3 py-1 rounded-md font-medium transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<button
|
||||
@click="showPermissionHelp = !showPermissionHelp"
|
||||
class="text-sm text-yellow-600 dark:text-yellow-400 underline hover:no-underline"
|
||||
>
|
||||
Need help?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Permission help -->
|
||||
<div
|
||||
v-if="showPermissionHelp"
|
||||
class="mt-3 p-3 bg-yellow-100 dark:bg-yellow-800/30 rounded-md"
|
||||
>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 font-medium mb-2">
|
||||
To enable camera access:
|
||||
</p>
|
||||
<ul class="text-xs text-yellow-600 dark:text-yellow-400 space-y-1">
|
||||
<li>• Click the camera icon in your browser's address bar</li>
|
||||
<li>• Select "Allow" when prompted for camera permission</li>
|
||||
<li>• Refresh the page if needed</li>
|
||||
<li>• Make sure no other app is using your camera</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Panel -->
|
||||
<div v-if="showSettings" class="mt-4 card p-4 animate-slide-up">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Video Settings</h3>
|
||||
|
||||
<!-- Video Devices -->
|
||||
<div v-if="videoDevices.length > 0" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>Camera</label
|
||||
>
|
||||
<select v-model="selectedVideoDevice" @change="switchVideoDevice" class="input-field">
|
||||
<option v-for="device in videoDevices" :key="device.deviceId" :value="device.deviceId">
|
||||
{{ device.label || `Camera ${videoDevices.indexOf(device) + 1}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Audio Devices -->
|
||||
<div v-if="audioDevices.length > 0" class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>Microphone</label
|
||||
>
|
||||
<select v-model="selectedAudioDevice" @change="switchAudioDevice" class="input-field">
|
||||
<option v-for="device in audioDevices" :key="device.deviceId" :value="device.deviceId">
|
||||
{{ device.label || `Microphone ${audioDevices.indexOf(device) + 1}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Video Quality Settings -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>Video Quality</label
|
||||
>
|
||||
<select v-model="selectedQuality" @change="changeVideoQuality" class="input-field">
|
||||
<option value="720p">HD (720p)</option>
|
||||
<option value="480p">SD (480p)</option>
|
||||
<option value="360p">Low (360p)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Mirror Video -->
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Mirror video</label>
|
||||
<button
|
||||
@click="shouldMirror = !shouldMirror"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2',
|
||||
shouldMirror ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-600',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
shouldMirror ? 'translate-x-6' : 'translate-x-1',
|
||||
]"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Close Settings -->
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button @click="showSettings = false" class="btn-secondary px-4 py-2">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { useWebRTCStore } from '../stores/webrtc'
|
||||
import { useMediaDevices } from '../composables/useMediaDevices'
|
||||
import { mediaService } from '../services/media'
|
||||
|
||||
const webrtcStore = useWebRTCStore()
|
||||
const {
|
||||
videoDevices,
|
||||
audioDevices,
|
||||
selectedVideoDevice,
|
||||
selectedAudioDevice,
|
||||
switchVideoDevice: switchVideo,
|
||||
switchAudioDevice: switchAudio,
|
||||
} = useMediaDevices()
|
||||
|
||||
// Template refs
|
||||
const videoRef = ref(null)
|
||||
|
||||
// Reactive state
|
||||
const mediaError = ref('')
|
||||
const isInitializing = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const showPermissionHelp = ref(false)
|
||||
const showSettings = ref(false)
|
||||
const showQualityIndicator = ref(false)
|
||||
const shouldMirror = ref(true)
|
||||
const selectedQuality = ref('720p')
|
||||
const videoQuality = ref('high')
|
||||
|
||||
// Computed
|
||||
const videoQualityText = computed(() => {
|
||||
switch (videoQuality.value) {
|
||||
case 'high':
|
||||
return 'HD'
|
||||
case 'medium':
|
||||
return 'SD'
|
||||
case 'low':
|
||||
return 'Low'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
// Quality presets
|
||||
const qualityPresets = {
|
||||
'720p': { width: 1280, height: 720 },
|
||||
'480p': { width: 640, height: 480 },
|
||||
'360p': { width: 480, height: 360 },
|
||||
}
|
||||
|
||||
// Methods
|
||||
const initializeMedia = async () => {
|
||||
try {
|
||||
isInitializing.value = true
|
||||
mediaError.value = ''
|
||||
loadingMessage.value = 'Accessing camera and microphone...'
|
||||
|
||||
const result = await webrtcStore.initializeLocalMedia()
|
||||
|
||||
if (!result.success) {
|
||||
mediaError.value = result.error
|
||||
showQualityIndicator.value = false
|
||||
} else {
|
||||
showQualityIndicator.value = true
|
||||
detectVideoQuality()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize media:', error)
|
||||
mediaError.value = 'Failed to access camera or microphone'
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVideo = () => {
|
||||
webrtcStore.toggleVideo()
|
||||
if (webrtcStore.isVideoEnabled) {
|
||||
detectVideoQuality()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAudio = () => {
|
||||
webrtcStore.toggleAudio()
|
||||
}
|
||||
|
||||
const switchVideoDevice = async () => {
|
||||
try {
|
||||
if (selectedVideoDevice.value) {
|
||||
loadingMessage.value = 'Switching camera...'
|
||||
isInitializing.value = true
|
||||
|
||||
// Stop current stream
|
||||
if (webrtcStore.localStream) {
|
||||
webrtcStore.localStream.getVideoTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
// Create new stream with selected device
|
||||
const constraints = {
|
||||
video: {
|
||||
deviceId: selectedVideoDevice.value,
|
||||
...qualityPresets[selectedQuality.value],
|
||||
},
|
||||
audio: selectedAudioDevice.value
|
||||
? {
|
||||
deviceId: selectedAudioDevice.value,
|
||||
}
|
||||
: true,
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
webrtcStore.localStream = stream
|
||||
|
||||
detectVideoQuality()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to switch video device:', error)
|
||||
mediaError.value = 'Failed to switch camera'
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchAudioDevice = async () => {
|
||||
try {
|
||||
if (selectedAudioDevice.value) {
|
||||
loadingMessage.value = 'Switching microphone...'
|
||||
isInitializing.value = true
|
||||
|
||||
// Similar logic for audio device switching
|
||||
if (webrtcStore.localStream) {
|
||||
webrtcStore.localStream.getAudioTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
const constraints = {
|
||||
video: selectedVideoDevice.value
|
||||
? {
|
||||
deviceId: selectedVideoDevice.value,
|
||||
...qualityPresets[selectedQuality.value],
|
||||
}
|
||||
: true,
|
||||
audio: {
|
||||
deviceId: selectedAudioDevice.value,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
webrtcStore.localStream = stream
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to switch audio device:', error)
|
||||
mediaError.value = 'Failed to switch microphone'
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const changeVideoQuality = async () => {
|
||||
try {
|
||||
if (webrtcStore.localStream && selectedQuality.value) {
|
||||
loadingMessage.value = 'Changing video quality...'
|
||||
isInitializing.value = true
|
||||
|
||||
const videoTrack = webrtcStore.localStream.getVideoTracks()[0]
|
||||
if (videoTrack) {
|
||||
const constraints = qualityPresets[selectedQuality.value]
|
||||
await videoTrack.applyConstraints(constraints)
|
||||
detectVideoQuality()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to change video quality:', error)
|
||||
mediaError.value = 'Failed to change video quality'
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const detectVideoQuality = () => {
|
||||
if (!webrtcStore.localStream) return
|
||||
|
||||
const videoTrack = webrtcStore.localStream.getVideoTracks()[0]
|
||||
if (videoTrack) {
|
||||
const settings = videoTrack.getSettings()
|
||||
const width = settings.width || 0
|
||||
|
||||
if (width >= 1280) {
|
||||
videoQuality.value = 'high'
|
||||
} else if (width >= 640) {
|
||||
videoQuality.value = 'medium'
|
||||
} else {
|
||||
videoQuality.value = 'low'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermissionDenied = () => {
|
||||
mediaError.value =
|
||||
'Camera and microphone access denied. Please allow permissions in your browser settings and refresh the page.'
|
||||
showPermissionHelp.value = true
|
||||
}
|
||||
|
||||
const checkMediaPermissions = async () => {
|
||||
try {
|
||||
const permissions = await mediaService.checkMediaPermissions()
|
||||
if (permissions.camera === 'denied' || permissions.microphone === 'denied') {
|
||||
handlePermissionDenied()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not check media permissions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for local stream changes
|
||||
watch(
|
||||
() => webrtcStore.localStream,
|
||||
(newStream) => {
|
||||
if (videoRef.value && newStream) {
|
||||
videoRef.value.srcObject = newStream
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Watch for video enabled changes
|
||||
watch(
|
||||
() => webrtcStore.isVideoEnabled,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
detectVideoQuality()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await initializeMedia()
|
||||
await checkMediaPermissions()
|
||||
|
||||
// Show quality indicator after 2 seconds
|
||||
setTimeout(() => {
|
||||
if (webrtcStore.hasLocalVideo) {
|
||||
showQualityIndicator.value = true
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup is handled by the WebRTC store
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.control-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* Custom animations */
|
||||
@keyframes pulse-slow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for settings */
|
||||
.settings-scroll::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.settings-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.settings-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
</style>
|
||||
86
videocall-frontend/src/components/WelcomeItem.vue
Normal file
86
videocall-frontend/src/components/WelcomeItem.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
videocall-frontend/src/components/icons/IconSupport.vue
Normal file
7
videocall-frontend/src/components/icons/IconSupport.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
19
videocall-frontend/src/components/icons/IconTooling.vue
Normal file
19
videocall-frontend/src/components/icons/IconTooling.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
95
videocall-frontend/src/composables/useMediaDevices.js
Normal file
95
videocall-frontend/src/composables/useMediaDevices.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// src/composables/useMediaDevices.js - Composable for media device management
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { mediaService } from '../services/media'
|
||||
|
||||
export function useMediaDevices() {
|
||||
const videoDevices = ref([])
|
||||
const audioDevices = ref([])
|
||||
const audioOutputDevices = ref([])
|
||||
const selectedVideoDevice = ref('')
|
||||
const selectedAudioDevice = ref('')
|
||||
const selectedAudioOutputDevice = ref('')
|
||||
|
||||
let deviceChangeHandler = null
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const devices = await mediaService.getMediaDevices()
|
||||
|
||||
videoDevices.value = devices.videoDevices
|
||||
audioDevices.value = devices.audioDevices
|
||||
audioOutputDevices.value = devices.audioOutputDevices
|
||||
|
||||
// Set default selected devices
|
||||
if (videoDevices.value.length > 0 && !selectedVideoDevice.value) {
|
||||
selectedVideoDevice.value = videoDevices.value[0].deviceId
|
||||
}
|
||||
|
||||
if (audioDevices.value.length > 0 && !selectedAudioDevice.value) {
|
||||
selectedAudioDevice.value = audioDevices.value[0].deviceId
|
||||
}
|
||||
|
||||
if (audioOutputDevices.value.length > 0 && !selectedAudioOutputDevice.value) {
|
||||
selectedAudioOutputDevice.value = audioOutputDevices.value[0].deviceId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load media devices:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const switchVideoDevice = async () => {
|
||||
// This will be implemented in the component that uses this composable
|
||||
console.log('Switching to video device:', selectedVideoDevice.value)
|
||||
}
|
||||
|
||||
const switchAudioDevice = async () => {
|
||||
// This will be implemented in the component that uses this composable
|
||||
console.log('Switching to audio device:', selectedAudioDevice.value)
|
||||
}
|
||||
|
||||
const switchAudioOutputDevice = async () => {
|
||||
// This will be implemented in the component that uses this composable
|
||||
console.log('Switching to audio output device:', selectedAudioOutputDevice.value)
|
||||
}
|
||||
|
||||
const refreshDevices = async () => {
|
||||
await loadDevices()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDevices()
|
||||
|
||||
// Listen for device changes
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.addEventListener) {
|
||||
deviceChangeHandler = () => {
|
||||
console.log('Media devices changed, reloading...')
|
||||
loadDevices()
|
||||
}
|
||||
navigator.mediaDevices.addEventListener('devicechange', deviceChangeHandler)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (
|
||||
deviceChangeHandler &&
|
||||
navigator.mediaDevices &&
|
||||
navigator.mediaDevices.removeEventListener
|
||||
) {
|
||||
navigator.mediaDevices.removeEventListener('devicechange', deviceChangeHandler)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
videoDevices,
|
||||
audioDevices,
|
||||
audioOutputDevices,
|
||||
selectedVideoDevice,
|
||||
selectedAudioDevice,
|
||||
selectedAudioOutputDevice,
|
||||
switchVideoDevice,
|
||||
switchAudioDevice,
|
||||
switchAudioOutputDevice,
|
||||
refreshDevices,
|
||||
loadDevices,
|
||||
}
|
||||
}
|
||||
99
videocall-frontend/src/main.js
Normal file
99
videocall-frontend/src/main.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// src/main.js - Vue.js application entry point with PWA support
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import { apiService } from './services/api'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Initialize API service with CSRF token
|
||||
apiService
|
||||
.initialize()
|
||||
.then(() => {
|
||||
console.log('API service initialized')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to initialize API service:', error)
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// PWA Service Worker Registration
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Use dynamic import to handle virtual modules
|
||||
import('virtual:pwa-register')
|
||||
.then(({ registerSW }) => {
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
// Show update available notification
|
||||
console.log('App update available')
|
||||
|
||||
// You can show a custom notification here
|
||||
const shouldUpdate = confirm('New version available! Click OK to update.')
|
||||
if (shouldUpdate) {
|
||||
updateSW(true)
|
||||
}
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready for offline use')
|
||||
|
||||
// Optional: Show offline ready notification
|
||||
// You can integrate this with your notification system
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
},
|
||||
})
|
||||
|
||||
// Optional: Periodic update checks (every 60 seconds)
|
||||
setInterval(() => {
|
||||
updateSW()
|
||||
}, 60000)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to register service worker:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// Network status monitoring for PWA
|
||||
window.addEventListener('online', () => {
|
||||
console.log('App is online')
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
console.log('App is offline')
|
||||
})
|
||||
|
||||
// Install prompt handling
|
||||
let deferredPrompt
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault()
|
||||
// Stash the event so it can be triggered later
|
||||
deferredPrompt = e
|
||||
console.log('PWA install prompt available')
|
||||
})
|
||||
|
||||
// Handle app installation
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('PWA was installed')
|
||||
deferredPrompt = null
|
||||
})
|
||||
|
||||
// Export install prompt function for components to use
|
||||
window.showInstallPrompt = async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
console.log(`User response to install prompt: ${outcome}`)
|
||||
deferredPrompt = null
|
||||
return outcome === 'accepted'
|
||||
}
|
||||
return false
|
||||
}
|
||||
609
videocall-frontend/src/router/index.js
Normal file
609
videocall-frontend/src/router/index.js
Normal file
@@ -0,0 +1,609 @@
|
||||
// src/router/index.js - Complete Vue Router configuration with auth guards and transitions
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useGlobalStore } from '../stores/global'
|
||||
import { useRoomsStore } from '../stores/rooms'
|
||||
|
||||
// Lazy load components for better performance
|
||||
const LoginForm = () => import('../components/LoginForm.vue')
|
||||
const Dashboard = () => import('../components/Dashboard.vue')
|
||||
const VideoCall = () => import('../components/VideoCall.vue')
|
||||
const NotFound = () => import('../components/NotFound.vue')
|
||||
const JoinRoom = () => import('../components/JoinRoom.vue')
|
||||
|
||||
// Route definitions with comprehensive metadata
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: 'Video Call Dashboard',
|
||||
description: 'Create or join video calls',
|
||||
showInNav: true,
|
||||
icon: 'home',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: LoginForm,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: 'Sign In - Video Call',
|
||||
description: 'Access the video calling platform',
|
||||
hideForAuth: true, // Hide this route if user is already authenticated
|
||||
showInNav: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/call/:roomId',
|
||||
name: 'VideoCall',
|
||||
component: VideoCall,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: 'Video Call',
|
||||
description: 'Active video call session',
|
||||
showInNav: false,
|
||||
fullScreen: true,
|
||||
preventLeave: true, // Show confirmation before leaving
|
||||
},
|
||||
props: true,
|
||||
beforeEnter: async (to, from, next) => {
|
||||
// Validate room ID format (UUID v4)
|
||||
const roomIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
if (!roomIdPattern.test(to.params.roomId)) {
|
||||
console.warn('Invalid room ID format:', to.params.roomId)
|
||||
next({ name: 'NotFound' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/join/:shortCode',
|
||||
name: 'JoinRoom',
|
||||
component: JoinRoom,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: 'Join Room',
|
||||
description: 'Join video call by room code',
|
||||
showInNav: false,
|
||||
},
|
||||
props: true,
|
||||
beforeEnter: (to, from, next) => {
|
||||
// Validate short code format (6-8 alphanumeric characters)
|
||||
const shortCodePattern = /^[A-Z0-9]{6,8}$/i
|
||||
|
||||
if (!shortCodePattern.test(to.params.shortCode)) {
|
||||
console.warn('Invalid short code format:', to.params.shortCode)
|
||||
next({ name: 'NotFound' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/room/:identifier',
|
||||
redirect: (to) => {
|
||||
// Redirect old room URLs to appropriate new format
|
||||
const identifier = to.params.identifier
|
||||
|
||||
// Check if it looks like a UUID (room ID)
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
if (uuidPattern.test(identifier)) {
|
||||
return { name: 'VideoCall', params: { roomId: identifier } }
|
||||
}
|
||||
|
||||
// Otherwise assume it's a short code
|
||||
return { name: 'JoinRoom', params: { shortCode: identifier } }
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: '/privacy',
|
||||
// name: 'Privacy',
|
||||
// component: () => import('../components/Privacy.vue'),
|
||||
// meta: {
|
||||
// requiresAuth: false,
|
||||
// title: 'Privacy Policy',
|
||||
// description: 'Our privacy policy and data handling practices',
|
||||
// showInNav: true,
|
||||
// icon: 'shield',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// path: '/terms',
|
||||
// name: 'Terms',
|
||||
// component: () => import('../components/Terms.vue'),
|
||||
// meta: {
|
||||
// requiresAuth: false,
|
||||
// title: 'Terms of Service',
|
||||
// description: 'Terms and conditions of use',
|
||||
// showInNav: true,
|
||||
// icon: 'document',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// path: '/help',
|
||||
// name: 'Help',
|
||||
// component: () => import('../components/Help.vue'),
|
||||
// meta: {
|
||||
// requiresAuth: false,
|
||||
// title: 'Help & Support',
|
||||
// description: 'Get help with using the video calling platform',
|
||||
// showInNav: true,
|
||||
// icon: 'question',
|
||||
// },
|
||||
// },
|
||||
// Catch-all route for 404
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: NotFound,
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
title: '404 - Page Not Found',
|
||||
description: 'The requested page could not be found',
|
||||
showInNav: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Create router instance with configuration
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// Handle scroll behavior for different scenarios
|
||||
if (savedPosition) {
|
||||
// When using browser back/forward buttons
|
||||
return savedPosition
|
||||
} else if (to.hash) {
|
||||
// When navigating to an anchor
|
||||
return { el: to.hash, behavior: 'smooth' }
|
||||
} else if (to.name !== from.name) {
|
||||
// When navigating to a different page
|
||||
return { top: 0, behavior: 'smooth' }
|
||||
}
|
||||
// Otherwise maintain current scroll position
|
||||
return {}
|
||||
},
|
||||
// Configure link active classes
|
||||
linkActiveClass: 'router-link-active',
|
||||
linkExactActiveClass: 'router-link-exact-active',
|
||||
})
|
||||
|
||||
// Global navigation guards
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const globalStore = useGlobalStore()
|
||||
const roomsStore = useRoomsStore()
|
||||
|
||||
console.log(`Navigating from ${from.name} to ${to.name}`)
|
||||
|
||||
// Set document title and meta tags
|
||||
updateDocumentMeta(to)
|
||||
|
||||
// Handle loading state
|
||||
if (to.name !== from.name) {
|
||||
globalStore.setLoading(true, 'Loading page...')
|
||||
}
|
||||
|
||||
// Check authentication requirement
|
||||
const requiresAuth = to.meta.requiresAuth
|
||||
const hideForAuth = to.meta.hideForAuth
|
||||
const isAuthenticated = globalStore.isAuthenticated
|
||||
|
||||
// If route should be hidden for authenticated users
|
||||
if (hideForAuth && isAuthenticated) {
|
||||
const redirectTo = to.query.redirect || from.fullPath || '/'
|
||||
next(redirectTo)
|
||||
return
|
||||
}
|
||||
|
||||
// If route requires auth and user is not authenticated
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
// Try to check auth status from server first
|
||||
await globalStore.checkAuthentication()
|
||||
|
||||
if (!globalStore.isAuthenticated) {
|
||||
// Store intended destination
|
||||
const redirectQuery = to.fullPath !== '/' ? { redirect: to.fullPath } : {}
|
||||
next({ name: 'Login', query: redirectQuery })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special route logic
|
||||
await handleSpecialRoutes(to, from, next, { globalStore, roomsStore })
|
||||
})
|
||||
|
||||
router.beforeResolve(async (to, from, next) => {
|
||||
// This runs after all in-component guards and async route components are resolved
|
||||
console.log(`Resolving route: ${to.name}`)
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach((to, from, failure) => {
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
// Clear loading state
|
||||
globalStore.setLoading(false)
|
||||
|
||||
if (failure) {
|
||||
console.error('Navigation failed:', failure)
|
||||
globalStore.addNotification('Navigation failed', 'error', 3000)
|
||||
} else {
|
||||
console.log(`Successfully navigated to ${to.name}`)
|
||||
|
||||
// Track page view (could integrate with analytics here)
|
||||
trackPageView(to)
|
||||
}
|
||||
|
||||
// Handle route-specific post-navigation logic
|
||||
handlePostNavigation(to, from)
|
||||
})
|
||||
|
||||
// Route-specific handlers
|
||||
async function handleSpecialRoutes(to, from, next, { globalStore, roomsStore }) {
|
||||
switch (to.name) {
|
||||
case 'VideoCall':
|
||||
// Special handling for video call routes
|
||||
if (from.name !== 'JoinRoom' && from.name !== 'Dashboard') {
|
||||
// If coming from external source, show warning about media permissions
|
||||
globalStore.addNotification(
|
||||
'Please allow camera and microphone access when prompted',
|
||||
'info',
|
||||
5000,
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'JoinRoom':
|
||||
// Check if we already have room info
|
||||
const shortCode = to.params.shortCode
|
||||
if (roomsStore.currentRoom?.short_code === shortCode) {
|
||||
// Redirect directly to video call if already in this room
|
||||
next({ name: 'VideoCall', params: { roomId: roomsStore.currentRoom.room_id } })
|
||||
return
|
||||
}
|
||||
break
|
||||
|
||||
case 'Dashboard':
|
||||
// Load room history when entering dashboard
|
||||
roomsStore.loadHistory()
|
||||
break
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
|
||||
function updateDocumentMeta(to) {
|
||||
// Update document title
|
||||
if (to.meta.title) {
|
||||
document.title = to.meta.title
|
||||
}
|
||||
|
||||
// Update meta description
|
||||
if (to.meta.description) {
|
||||
let metaDescription = document.querySelector('meta[name="description"]')
|
||||
if (!metaDescription) {
|
||||
metaDescription = document.createElement('meta')
|
||||
metaDescription.setAttribute('name', 'description')
|
||||
document.head.appendChild(metaDescription)
|
||||
}
|
||||
metaDescription.setAttribute('content', to.meta.description)
|
||||
}
|
||||
|
||||
// Update Open Graph tags
|
||||
updateOpenGraphTags(to)
|
||||
}
|
||||
|
||||
function updateOpenGraphTags(to) {
|
||||
const ogTags = [
|
||||
{ property: 'og:title', content: to.meta.title },
|
||||
{ property: 'og:description', content: to.meta.description },
|
||||
{ property: 'og:url', content: window.location.href },
|
||||
]
|
||||
|
||||
ogTags.forEach(({ property, content }) => {
|
||||
if (!content) return
|
||||
|
||||
let tag = document.querySelector(`meta[property="${property}"]`)
|
||||
if (!tag) {
|
||||
tag = document.createElement('meta')
|
||||
tag.setAttribute('property', property)
|
||||
document.head.appendChild(tag)
|
||||
}
|
||||
tag.setAttribute('content', content)
|
||||
})
|
||||
}
|
||||
|
||||
function trackPageView(to) {
|
||||
// Basic page view tracking (could be enhanced with analytics)
|
||||
if (typeof gtag !== 'undefined') {
|
||||
gtag('config', 'GA_MEASUREMENT_ID', {
|
||||
page_title: to.meta.title,
|
||||
page_location: window.location.href,
|
||||
page_path: to.path,
|
||||
})
|
||||
}
|
||||
|
||||
// Custom analytics could go here
|
||||
console.log('Page view:', {
|
||||
path: to.path,
|
||||
name: to.name,
|
||||
title: to.meta.title,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
function handlePostNavigation(to, from) {
|
||||
// Handle route-specific post-navigation tasks
|
||||
|
||||
// Add body classes for styling
|
||||
document.body.className = document.body.className
|
||||
.replace(/route-\S+/g, '') // Remove existing route classes
|
||||
.trim()
|
||||
|
||||
if (to.name) {
|
||||
document.body.classList.add(`route-${to.name.toLowerCase()}`)
|
||||
}
|
||||
|
||||
// Handle full-screen routes
|
||||
if (to.meta.fullScreen) {
|
||||
document.body.classList.add('fullscreen-route')
|
||||
} else {
|
||||
document.body.classList.remove('fullscreen-route')
|
||||
}
|
||||
|
||||
// Handle prevent leave for important routes
|
||||
if (to.meta.preventLeave) {
|
||||
setupBeforeUnloadHandler()
|
||||
} else {
|
||||
removeBeforeUnloadHandler()
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent leaving important pages accidentally
|
||||
let beforeUnloadHandler = null
|
||||
|
||||
function setupBeforeUnloadHandler() {
|
||||
beforeUnloadHandler = (event) => {
|
||||
const message = 'Are you sure you want to leave this video call?'
|
||||
event.preventDefault()
|
||||
event.returnValue = message
|
||||
return message
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
}
|
||||
|
||||
function removeBeforeUnloadHandler() {
|
||||
if (beforeUnloadHandler) {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
beforeUnloadHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation helpers
|
||||
export const navigationHelpers = {
|
||||
/**
|
||||
* Navigate to room by code or ID
|
||||
*/
|
||||
async goToRoom(identifier, options = {}) {
|
||||
const { replace = false } = options
|
||||
|
||||
// Determine if it's a room ID (UUID) or short code
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
const route = uuidPattern.test(identifier)
|
||||
? { name: 'VideoCall', params: { roomId: identifier } }
|
||||
: { name: 'JoinRoom', params: { shortCode: identifier } }
|
||||
|
||||
if (replace) {
|
||||
return router.replace(route)
|
||||
} else {
|
||||
return router.push(route)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Navigate back with fallback
|
||||
*/
|
||||
goBack(fallbackRoute = { name: 'Dashboard' }) {
|
||||
if (window.history.length > 1) {
|
||||
router.go(-1)
|
||||
} else {
|
||||
router.push(fallbackRoute)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get navigation items for menus
|
||||
*/
|
||||
getNavigationItems(authenticated = false) {
|
||||
return routes
|
||||
.filter((route) => route.meta?.showInNav)
|
||||
.filter((route) => {
|
||||
if (route.meta?.requiresAuth && !authenticated) return false
|
||||
if (route.meta?.hideForAuth && authenticated) return false
|
||||
return true
|
||||
})
|
||||
.map((route) => ({
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
title: route.meta?.title || route.name,
|
||||
icon: route.meta?.icon,
|
||||
description: route.meta?.description,
|
||||
}))
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if current route matches
|
||||
*/
|
||||
isCurrentRoute(routeName) {
|
||||
return router.currentRoute.value.name === routeName
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current route info
|
||||
*/
|
||||
getCurrentRoute() {
|
||||
const route = router.currentRoute.value
|
||||
return {
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
params: route.params,
|
||||
query: route.query,
|
||||
meta: route.meta,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Route transition configurations
|
||||
export const routeTransitions = {
|
||||
// Default transition
|
||||
default: {
|
||||
name: 'fade',
|
||||
mode: 'out-in',
|
||||
},
|
||||
|
||||
// Slide transition for mobile
|
||||
slide: {
|
||||
name: 'slide',
|
||||
mode: 'out-in',
|
||||
},
|
||||
|
||||
// No transition for video calls
|
||||
none: {
|
||||
name: '',
|
||||
mode: 'out-in',
|
||||
},
|
||||
}
|
||||
|
||||
// Route middleware system
|
||||
const middlewares = {
|
||||
auth: async (to, from, next) => {
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
if (!globalStore.isAuthenticated) {
|
||||
await globalStore.checkAuthentication()
|
||||
|
||||
if (!globalStore.isAuthenticated) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
},
|
||||
|
||||
guest: (to, from, next) => {
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
if (globalStore.isAuthenticated) {
|
||||
next({ name: 'Dashboard' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
},
|
||||
|
||||
validateRoom: async (to, from, next) => {
|
||||
const roomsStore = useRoomsStore()
|
||||
const roomId = to.params.roomId
|
||||
|
||||
if (roomId) {
|
||||
const result = await roomsStore.getRoomInfo(roomId)
|
||||
|
||||
if (!result.success) {
|
||||
next({ name: 'NotFound' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
},
|
||||
}
|
||||
|
||||
// Apply middleware to routes
|
||||
function applyMiddleware(to, from, next, middlewareList = []) {
|
||||
if (middlewareList.length === 0) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const middleware = middlewares[middlewareList[0]]
|
||||
|
||||
if (!middleware) {
|
||||
console.warn(`Middleware ${middlewareList[0]} not found`)
|
||||
applyMiddleware(to, from, next, middlewareList.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
middleware(to, from, (nextArg) => {
|
||||
if (nextArg) {
|
||||
next(nextArg)
|
||||
} else {
|
||||
applyMiddleware(to, from, next, middlewareList.slice(1))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Error handling for navigation
|
||||
router.onError((error, to, from) => {
|
||||
console.error('Router error:', error)
|
||||
|
||||
const globalStore = useGlobalStore()
|
||||
globalStore.setLoading(false)
|
||||
|
||||
// Handle specific error types
|
||||
if (error.name === 'ChunkLoadError') {
|
||||
// Handle code splitting errors
|
||||
globalStore.addNotification('Failed to load page. Please refresh and try again.', 'error', 8000)
|
||||
|
||||
// Retry navigation after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
} else {
|
||||
globalStore.addNotification('Navigation error occurred', 'error', 5000)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on app unmount
|
||||
export function cleanupRouter() {
|
||||
removeBeforeUnloadHandler()
|
||||
document.body.className = document.body.className
|
||||
.replace(/route-\S+/g, '')
|
||||
.replace('fullscreen-route', '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// Development helpers
|
||||
if (import.meta.env.DEV) {
|
||||
// Add router debugging in development
|
||||
router.beforeEach((to, from, next) => {
|
||||
console.group('🧭 Router Navigation')
|
||||
console.log('From:', from.name, from.path)
|
||||
console.log('To:', to.name, to.path)
|
||||
console.log('Query:', to.query)
|
||||
console.log('Params:', to.params)
|
||||
console.log('Meta:', to.meta)
|
||||
console.groupEnd()
|
||||
next()
|
||||
})
|
||||
|
||||
// Expose router to global scope for debugging
|
||||
window.__router__ = router
|
||||
window.__navigationHelpers__ = navigationHelpers
|
||||
}
|
||||
|
||||
// Export router instance
|
||||
export default router
|
||||
|
||||
// Export route configurations for testing
|
||||
export { routes, middlewares }
|
||||
258
videocall-frontend/src/services/api.js
Normal file
258
videocall-frontend/src/services/api.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// src/services/api.js - API service layer with CSRF support
|
||||
import axios from 'axios'
|
||||
|
||||
// Create axios instance with base configuration
|
||||
const apiClient = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // Include session cookies
|
||||
})
|
||||
|
||||
// Get CSRF token from cookie
|
||||
const getCSRFToken = () => {
|
||||
const name = 'csrftoken'
|
||||
let cookieValue = null
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';')
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim()
|
||||
if (cookie.substring(0, name.length + 1) === name + '=') {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue
|
||||
}
|
||||
|
||||
// Request interceptor for adding auth headers and logging
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`)
|
||||
|
||||
// Add CSRF token for non-GET requests
|
||||
if (config.method && !['get', 'head', 'options'].includes(config.method.toLowerCase())) {
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) {
|
||||
config.headers['X-CSRFToken'] = csrfToken
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
console.error('API Request Error:', error)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// Response interceptor for handling errors and logging
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`API Response: ${response.status} ${response.config.url}`)
|
||||
return response
|
||||
},
|
||||
async (error) => {
|
||||
console.error('API Response Error:', {
|
||||
status: error.response?.status,
|
||||
message: error.response?.data?.error || error.message,
|
||||
url: error.config?.url,
|
||||
})
|
||||
|
||||
// Handle specific error cases
|
||||
if (error.response?.status === 401) {
|
||||
// Unauthorized - redirect to login if needed
|
||||
console.log('Unauthorized access detected')
|
||||
} else if (error.response?.status === 403) {
|
||||
// Forbidden - might be CSRF issue, try to get new token
|
||||
console.log('Forbidden access - possible CSRF issue')
|
||||
|
||||
// Try to refresh CSRF token and retry once
|
||||
if (!error.config._retry) {
|
||||
error.config._retry = true
|
||||
try {
|
||||
await getCsrfToken()
|
||||
return apiClient(error.config)
|
||||
} catch (retryError) {
|
||||
console.error('Failed to retry with new CSRF token:', retryError)
|
||||
}
|
||||
}
|
||||
} else if (error.response?.status === 429) {
|
||||
// Rate limited
|
||||
console.log('Rate limit exceeded')
|
||||
} else if (error.response?.status >= 500) {
|
||||
// Server error
|
||||
console.log('Server error occurred')
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// Get CSRF token endpoint
|
||||
const getCsrfToken = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/csrf/')
|
||||
const token = response.data.csrfToken
|
||||
if (token) {
|
||||
// Set token for future requests
|
||||
apiClient.defaults.headers.common['X-CSRFToken'] = token
|
||||
}
|
||||
return token
|
||||
} catch (error) {
|
||||
console.warn('Failed to get CSRF token:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize CSRF token
|
||||
let csrfInitialized = false
|
||||
|
||||
const initializeCSRF = async () => {
|
||||
if (!csrfInitialized) {
|
||||
const token = await getCsrfToken()
|
||||
csrfInitialized = !!token
|
||||
}
|
||||
}
|
||||
|
||||
// API service object with all endpoint methods
|
||||
export const apiService = {
|
||||
// Initialize CSRF
|
||||
async initialize() {
|
||||
await initializeCSRF()
|
||||
},
|
||||
|
||||
// Authentication endpoints
|
||||
async login(password) {
|
||||
await initializeCSRF()
|
||||
return apiClient.post('/auth/login/', { password })
|
||||
},
|
||||
|
||||
async logout() {
|
||||
return apiClient.post('/auth/logout/')
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
return apiClient.get('/auth/check/')
|
||||
},
|
||||
|
||||
// Room management endpoints
|
||||
async createRoom() {
|
||||
await initializeCSRF()
|
||||
return apiClient.post('/rooms/create/')
|
||||
},
|
||||
|
||||
async getRoomInfo(roomId) {
|
||||
return apiClient.get(`/rooms/${roomId}/`)
|
||||
},
|
||||
|
||||
async joinRoom(roomIdentifier) {
|
||||
await initializeCSRF()
|
||||
return apiClient.post('/rooms/join/', {
|
||||
room_identifier: roomIdentifier,
|
||||
})
|
||||
},
|
||||
|
||||
async leaveRoom(roomId) {
|
||||
await initializeCSRF()
|
||||
return apiClient.post(`/rooms/${roomId}/leave/`)
|
||||
},
|
||||
|
||||
async deleteRoom(roomId) {
|
||||
await initializeCSRF()
|
||||
return apiClient.delete(`/rooms/${roomId}/delete/`)
|
||||
},
|
||||
|
||||
// System endpoints
|
||||
async healthCheck() {
|
||||
return apiClient.get('/health/')
|
||||
},
|
||||
}
|
||||
|
||||
// Utility functions for API handling
|
||||
export const apiUtils = {
|
||||
/**
|
||||
* Extract error message from API response
|
||||
*/
|
||||
getErrorMessage(error) {
|
||||
if (error.response?.data?.error) {
|
||||
return error.response.data.error
|
||||
} else if (error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
} else if (error.message) {
|
||||
return error.message
|
||||
} else {
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if error is due to network issues
|
||||
*/
|
||||
isNetworkError(error) {
|
||||
return !error.response || error.code === 'NETWORK_ERROR'
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if error is due to authentication
|
||||
*/
|
||||
isAuthError(error) {
|
||||
return error.response?.status === 401
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if error is due to CSRF
|
||||
*/
|
||||
isCSRFError(error) {
|
||||
return error.response?.status === 403
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if error is due to rate limiting
|
||||
*/
|
||||
isRateLimitError(error) {
|
||||
return error.response?.status === 429
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry API call with exponential backoff
|
||||
*/
|
||||
async retryWithBackoff(apiCall, maxRetries = 3, baseDelay = 1000) {
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await apiCall()
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// Retry CSRF errors once
|
||||
if (this.isCSRFError(error) && attempt === 0) {
|
||||
await initializeCSRF()
|
||||
continue
|
||||
}
|
||||
|
||||
// Don't retry on client errors (4xx) except CSRF
|
||||
if (
|
||||
error.response?.status >= 400 &&
|
||||
error.response?.status < 500 &&
|
||||
!this.isCSRFError(error)
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
// Wait before retrying (exponential backoff)
|
||||
if (attempt < maxRetries - 1) {
|
||||
const delay = baseDelay * Math.pow(2, attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
},
|
||||
}
|
||||
|
||||
160
videocall-frontend/src/services/media.js
Normal file
160
videocall-frontend/src/services/media.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// src/services/media.js - Media handling utilities
|
||||
export const mediaService = {
|
||||
/**
|
||||
* Get available media devices
|
||||
*/
|
||||
async getMediaDevices() {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
|
||||
return {
|
||||
videoDevices: devices.filter((device) => device.kind === 'videoinput'),
|
||||
audioDevices: devices.filter((device) => device.kind === 'audioinput'),
|
||||
audioOutputDevices: devices.filter((device) => device.kind === 'audiooutput'),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get media devices:', error)
|
||||
return {
|
||||
videoDevices: [],
|
||||
audioDevices: [],
|
||||
audioOutputDevices: [],
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has granted media permissions
|
||||
*/
|
||||
async checkMediaPermissions() {
|
||||
try {
|
||||
const permissions = await Promise.all([
|
||||
navigator.permissions.query({ name: 'camera' }),
|
||||
navigator.permissions.query({ name: 'microphone' }),
|
||||
])
|
||||
|
||||
return {
|
||||
camera: permissions[0].state,
|
||||
microphone: permissions[1].state,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check media permissions:', error)
|
||||
return {
|
||||
camera: 'unknown',
|
||||
microphone: 'unknown',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get optimal media constraints based on device capabilities
|
||||
*/
|
||||
async getOptimalConstraints() {
|
||||
try {
|
||||
const devices = await this.getMediaDevices()
|
||||
|
||||
// Default constraints
|
||||
let constraints = {
|
||||
video: {
|
||||
width: { ideal: 1280, max: 1920 },
|
||||
height: { ideal: 720, max: 1080 },
|
||||
frameRate: { ideal: 30, max: 60 },
|
||||
},
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Adjust constraints based on available devices
|
||||
if (devices.videoDevices.length === 0) {
|
||||
constraints.video = false
|
||||
}
|
||||
|
||||
if (devices.audioDevices.length === 0) {
|
||||
constraints.audio = false
|
||||
}
|
||||
|
||||
return constraints
|
||||
} catch (error) {
|
||||
console.error('Failed to get optimal constraints:', error)
|
||||
return {
|
||||
video: true,
|
||||
audio: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test media access without keeping the stream
|
||||
*/
|
||||
async testMediaAccess() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
})
|
||||
|
||||
// Immediately stop all tracks
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
|
||||
return {
|
||||
success: true,
|
||||
hasVideo: stream.getVideoTracks().length > 0,
|
||||
hasAudio: stream.getAudioTracks().length > 0,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.name,
|
||||
message: this.getMediaErrorMessage(error),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for media errors
|
||||
*/
|
||||
getMediaErrorMessage(error) {
|
||||
switch (error.name) {
|
||||
case 'NotAllowedError':
|
||||
return 'Camera and microphone access denied. Please allow permissions and try again.'
|
||||
case 'NotFoundError':
|
||||
return 'No camera or microphone found on this device.'
|
||||
case 'NotReadableError':
|
||||
return 'Camera or microphone is already in use by another application.'
|
||||
case 'OverconstrainedError':
|
||||
return 'Camera or microphone does not support the requested settings.'
|
||||
case 'SecurityError':
|
||||
return 'Media access blocked due to security restrictions.'
|
||||
case 'AbortError':
|
||||
return 'Media access was aborted.'
|
||||
default:
|
||||
return 'Failed to access camera or microphone.'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create media stream with fallback options
|
||||
*/
|
||||
async createStreamWithFallback(preferredConstraints) {
|
||||
const fallbackOptions = [
|
||||
preferredConstraints,
|
||||
{ video: true, audio: true }, // Basic constraints
|
||||
{ video: { width: 640, height: 480 }, audio: true }, // Lower resolution
|
||||
{ video: false, audio: true }, // Audio only
|
||||
]
|
||||
|
||||
for (const constraints of fallbackOptions) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
return { success: true, stream, constraints }
|
||||
} catch (error) {
|
||||
console.warn('Failed to create stream with constraints:', constraints, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to create media stream with any constraints')
|
||||
},
|
||||
}
|
||||
85
videocall-frontend/src/services/storage.js
Normal file
85
videocall-frontend/src/services/storage.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// src/services/storage.js - Local storage utilities
|
||||
export const storageService = {
|
||||
/**
|
||||
* Safely get item from localStorage
|
||||
*/
|
||||
getItem(key, defaultValue = null) {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : defaultValue
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get item from localStorage: ${key}`, error)
|
||||
return defaultValue
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely set item in localStorage
|
||||
*/
|
||||
setItem(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn(`Failed to set item in localStorage: ${key}`, error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Safely remove item from localStorage
|
||||
*/
|
||||
removeItem(key) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn(`Failed to remove item from localStorage: ${key}`, error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all items from localStorage
|
||||
*/
|
||||
clear() {
|
||||
try {
|
||||
localStorage.clear()
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear localStorage', error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get storage usage information
|
||||
*/
|
||||
getStorageInfo() {
|
||||
try {
|
||||
const used = new Blob(Object.values(localStorage)).size
|
||||
return {
|
||||
used: used,
|
||||
usedFormatted: this.formatBytes(used),
|
||||
available: 5 * 1024 * 1024 - used, // Assuming 5MB limit
|
||||
availableFormatted: this.formatBytes(5 * 1024 * 1024 - used),
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get storage info', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format bytes to human readable format
|
||||
*/
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
},
|
||||
}
|
||||
192
videocall-frontend/src/services/utils.js
Normal file
192
videocall-frontend/src/services/utils.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// src/services/utils.js - Utility functions
|
||||
export const utils = {
|
||||
/**
|
||||
* Generate random string
|
||||
*/
|
||||
generateRandomString(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
async copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return { success: true }
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
const success = document.execCommand('copy')
|
||||
textArea.remove()
|
||||
|
||||
return { success }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
debounce(func, wait) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Throttle function
|
||||
*/
|
||||
throttle(func, limit) {
|
||||
let inThrottle
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format time duration
|
||||
*/
|
||||
formatDuration(seconds) {
|
||||
const hrs = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hrs > 0) {
|
||||
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
} else {
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
formatRelativeTime(date) {
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now - date) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'Just now'
|
||||
} else if (diffInSeconds < 3600) {
|
||||
const minutes = Math.floor(diffInSeconds / 60)
|
||||
return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
|
||||
} else if (diffInSeconds < 86400) {
|
||||
const hours = Math.floor(diffInSeconds / 3600)
|
||||
return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
||||
} else {
|
||||
const days = Math.floor(diffInSeconds / 86400)
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate URL
|
||||
*/
|
||||
isValidUrl(string) {
|
||||
try {
|
||||
new URL(string)
|
||||
return true
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate QR code data URL
|
||||
*/
|
||||
async generateQRCode(text, options = {}) {
|
||||
const QRCode = await import('qrcode')
|
||||
|
||||
const defaultOptions = {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const dataURL = await QRCode.toDataURL(text, { ...defaultOptions, ...options })
|
||||
return { success: true, dataURL }
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Detect mobile device
|
||||
*/
|
||||
isMobileDevice() {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Detect iOS device
|
||||
*/
|
||||
isIOSDevice() {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get browser info
|
||||
*/
|
||||
getBrowserInfo() {
|
||||
const userAgent = navigator.userAgent
|
||||
let browserName = 'Unknown'
|
||||
let browserVersion = 'Unknown'
|
||||
|
||||
if (userAgent.indexOf('Chrome') > -1) {
|
||||
browserName = 'Chrome'
|
||||
browserVersion = userAgent.match(/Chrome\/([0-9.]+)/)?.[1] || 'Unknown'
|
||||
} else if (userAgent.indexOf('Safari') > -1) {
|
||||
browserName = 'Safari'
|
||||
browserVersion = userAgent.match(/Version\/([0-9.]+)/)?.[1] || 'Unknown'
|
||||
} else if (userAgent.indexOf('Firefox') > -1) {
|
||||
browserName = 'Firefox'
|
||||
browserVersion = userAgent.match(/Firefox\/([0-9.]+)/)?.[1] || 'Unknown'
|
||||
} else if (userAgent.indexOf('Edge') > -1) {
|
||||
browserName = 'Edge'
|
||||
browserVersion = userAgent.match(/Edge\/([0-9.]+)/)?.[1] || 'Unknown'
|
||||
}
|
||||
|
||||
return {
|
||||
name: browserName,
|
||||
version: browserVersion,
|
||||
userAgent: userAgent,
|
||||
isMobile: this.isMobileDevice(),
|
||||
isIOS: this.isIOSDevice(),
|
||||
}
|
||||
},
|
||||
}
|
||||
136
videocall-frontend/src/services/webrtc.js
Normal file
136
videocall-frontend/src/services/webrtc.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// src/services/webrtc.js - WebRTC utility functions
|
||||
export const webrtcService = {
|
||||
/**
|
||||
* Get STUN/TURN server configuration
|
||||
*/
|
||||
getIceServerConfig() {
|
||||
return {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
// Add TURN servers for production
|
||||
// {
|
||||
// urls: 'turn:your-turn-server.com:3478',
|
||||
// username: 'username',
|
||||
// credential: 'password'
|
||||
// }
|
||||
],
|
||||
iceCandidatePoolSize: 10,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test WebRTC support
|
||||
*/
|
||||
isWebRTCSupported() {
|
||||
return !!(
|
||||
window.RTCPeerConnection ||
|
||||
window.webkitRTCPeerConnection ||
|
||||
window.mozRTCPeerConnection
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get WebRTC statistics
|
||||
*/
|
||||
async getConnectionStats(peerConnection) {
|
||||
if (!peerConnection) return null
|
||||
|
||||
try {
|
||||
const stats = await peerConnection.getStats()
|
||||
const result = {
|
||||
video: {},
|
||||
audio: {},
|
||||
connection: {},
|
||||
}
|
||||
|
||||
stats.forEach((report) => {
|
||||
if (report.type === 'inbound-rtp' && report.mediaType === 'video') {
|
||||
result.video.inbound = {
|
||||
bytesReceived: report.bytesReceived,
|
||||
packetsReceived: report.packetsReceived,
|
||||
packetsLost: report.packetsLost,
|
||||
frameWidth: report.frameWidth,
|
||||
frameHeight: report.frameHeight,
|
||||
framesPerSecond: report.framesPerSecond,
|
||||
}
|
||||
} else if (report.type === 'outbound-rtp' && report.mediaType === 'video') {
|
||||
result.video.outbound = {
|
||||
bytesSent: report.bytesSent,
|
||||
packetsSent: report.packetsSent,
|
||||
frameWidth: report.frameWidth,
|
||||
frameHeight: report.frameHeight,
|
||||
framesPerSecond: report.framesPerSecond,
|
||||
}
|
||||
} else if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
||||
result.connection = {
|
||||
currentRoundTripTime: report.currentRoundTripTime,
|
||||
availableOutgoingBitrate: report.availableOutgoingBitrate,
|
||||
bytesReceived: report.bytesReceived,
|
||||
bytesSent: report.bytesSent,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to get connection stats:', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Monitor connection quality
|
||||
*/
|
||||
createQualityMonitor(peerConnection, callback, interval = 5000) {
|
||||
if (!peerConnection || typeof callback !== 'function') {
|
||||
return null
|
||||
}
|
||||
|
||||
const monitor = setInterval(async () => {
|
||||
try {
|
||||
const stats = await this.getConnectionStats(peerConnection)
|
||||
if (stats) {
|
||||
const quality = this.calculateQuality(stats)
|
||||
callback(quality, stats)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Quality monitoring error:', error)
|
||||
}
|
||||
}, interval)
|
||||
|
||||
return monitor
|
||||
},
|
||||
|
||||
/**
|
||||
* Calculate connection quality score (0-100)
|
||||
*/
|
||||
calculateQuality(stats) {
|
||||
let score = 100
|
||||
|
||||
// Reduce score based on packet loss
|
||||
if (stats.video.inbound?.packetsLost && stats.video.inbound?.packetsReceived) {
|
||||
const lossRate = stats.video.inbound.packetsLost / stats.video.inbound.packetsReceived
|
||||
score -= lossRate * 50 // Up to 50 points for packet loss
|
||||
}
|
||||
|
||||
// Reduce score based on round trip time
|
||||
if (stats.connection?.currentRoundTripTime) {
|
||||
const rtt = stats.connection.currentRoundTripTime * 1000 // Convert to ms
|
||||
if (rtt > 150) {
|
||||
score -= Math.min(30, (rtt - 150) / 10) // Up to 30 points for high latency
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce score based on low frame rate
|
||||
if (stats.video.inbound?.framesPerSecond) {
|
||||
const fps = stats.video.inbound.framesPerSecond
|
||||
if (fps < 15) {
|
||||
score -= (15 - fps) * 2 // Up to 30 points for low FPS
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(score)))
|
||||
},
|
||||
}
|
||||
12
videocall-frontend/src/stores/counter.js
Normal file
12
videocall-frontend/src/stores/counter.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
139
videocall-frontend/src/stores/global.js
Normal file
139
videocall-frontend/src/stores/global.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// src/stores/global.js - Global application state management
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '../services/api'
|
||||
|
||||
export const useGlobalStore = defineStore('global', () => {
|
||||
// State
|
||||
const isAuthenticated = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const loadingMessage = ref('')
|
||||
const notifications = ref([])
|
||||
const isDarkMode = ref(false)
|
||||
const isOnline = ref(navigator.onLine)
|
||||
|
||||
// Computed
|
||||
const canUseApp = computed(() => isAuthenticated.value && isOnline.value)
|
||||
|
||||
// Actions
|
||||
const setAuthenticated = (value) => {
|
||||
isAuthenticated.value = value
|
||||
}
|
||||
|
||||
const setLoading = (loading, message = '') => {
|
||||
isLoading.value = loading
|
||||
loadingMessage.value = message
|
||||
}
|
||||
|
||||
const addNotification = (message, type = 'info', duration = 5000) => {
|
||||
const id = Date.now() + Math.random()
|
||||
const notification = { id, message, type }
|
||||
|
||||
notifications.value.push(notification)
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeNotification(id)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const removeNotification = (id) => {
|
||||
const index = notifications.value.findIndex((n) => n.id === id)
|
||||
if (index > -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const clearNotifications = () => {
|
||||
notifications.value = []
|
||||
}
|
||||
|
||||
const setDarkMode = (dark) => {
|
||||
isDarkMode.value = dark
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
}
|
||||
|
||||
const setNetworkStatus = (online) => {
|
||||
isOnline.value = online
|
||||
if (online) {
|
||||
addNotification('Connection restored', 'success', 3000)
|
||||
} else {
|
||||
addNotification('Connection lost. Some features may not work.', 'error', 0)
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuthentication = async () => {
|
||||
try {
|
||||
setLoading(true, 'Checking authentication...')
|
||||
const response = await apiService.checkAuth()
|
||||
setAuthenticated(response.data.authenticated)
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
setAuthenticated(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const login = async (password) => {
|
||||
try {
|
||||
setLoading(true, 'Authenticating...')
|
||||
const response = await apiService.login(password)
|
||||
|
||||
if (response.data.success) {
|
||||
setAuthenticated(true)
|
||||
addNotification('Login successful', 'success', 3000)
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, error: 'Login failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.error || 'Authentication failed'
|
||||
addNotification(errorMessage, 'error', 5000)
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiService.logout()
|
||||
setAuthenticated(false)
|
||||
addNotification('Logged out successfully', 'info', 3000)
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
// Force logout even if API call fails
|
||||
setAuthenticated(false)
|
||||
addNotification('Logged out', 'info', 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
loadingMessage,
|
||||
notifications,
|
||||
isDarkMode,
|
||||
isOnline,
|
||||
|
||||
// Computed
|
||||
canUseApp,
|
||||
|
||||
// Actions
|
||||
setAuthenticated,
|
||||
setLoading,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearNotifications,
|
||||
setDarkMode,
|
||||
setNetworkStatus,
|
||||
checkAuthentication,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
360
videocall-frontend/src/stores/rooms.js
Normal file
360
videocall-frontend/src/stores/rooms.js
Normal file
@@ -0,0 +1,360 @@
|
||||
// src/stores/rooms.js - Complete room management state
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiService } from '../services/api'
|
||||
import { useGlobalStore } from './global'
|
||||
import { utils } from '../services/utils'
|
||||
|
||||
export const useRoomsStore = defineStore('rooms', () => {
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
// State
|
||||
const currentRoom = ref(null)
|
||||
const roomHistory = ref([])
|
||||
const isCreatingRoom = ref(false)
|
||||
const isJoiningRoom = ref(false)
|
||||
const isLeavingRoom = ref(false)
|
||||
const roomParticipants = ref([])
|
||||
|
||||
// Computed
|
||||
const hasActiveRoom = computed(() => currentRoom.value !== null)
|
||||
const currentRoomId = computed(() => currentRoom.value?.room_id || null)
|
||||
const currentRoomCode = computed(() => currentRoom.value?.short_code || null)
|
||||
const participantCount = computed(() => roomParticipants.value.length)
|
||||
const canJoinRoom = computed(() => !isJoiningRoom.value && !hasActiveRoom.value)
|
||||
|
||||
// Actions
|
||||
const createRoom = async () => {
|
||||
try {
|
||||
isCreatingRoom.value = true
|
||||
globalStore.setLoading(true, 'Creating room...')
|
||||
|
||||
const response = await apiService.createRoom()
|
||||
const roomData = response.data
|
||||
|
||||
currentRoom.value = {
|
||||
room_id: roomData.room_id,
|
||||
short_code: roomData.short_code,
|
||||
room_url: roomData.room_url,
|
||||
qr_code: roomData.qr_code,
|
||||
expires_at: roomData.expires_at,
|
||||
max_participants: roomData.max_participants,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
addToHistory(currentRoom.value)
|
||||
globalStore.addNotification('Room created successfully', 'success', 3000)
|
||||
|
||||
return { success: true, room: currentRoom.value }
|
||||
} catch (error) {
|
||||
console.error('Failed to create room:', error)
|
||||
const errorMessage = error.response?.data?.error || 'Failed to create room'
|
||||
globalStore.addNotification(errorMessage, 'error', 5000)
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isCreatingRoom.value = false
|
||||
globalStore.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = async (roomIdentifier) => {
|
||||
try {
|
||||
isJoiningRoom.value = true
|
||||
globalStore.setLoading(true, 'Joining room...')
|
||||
|
||||
// Clean room identifier (remove URL parts if present)
|
||||
let cleanIdentifier = roomIdentifier.trim()
|
||||
|
||||
// Extract room code from URL if it's a full URL
|
||||
if (cleanIdentifier.includes('/join/')) {
|
||||
const match = cleanIdentifier.match(/\/join\/([A-Z0-9]+)/)
|
||||
if (match) {
|
||||
cleanIdentifier = match[1]
|
||||
}
|
||||
} else if (cleanIdentifier.includes('/call/')) {
|
||||
const match = cleanIdentifier.match(/\/call\/([a-f0-9-]+)/)
|
||||
if (match) {
|
||||
cleanIdentifier = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiService.joinRoom(cleanIdentifier)
|
||||
const roomData = response.data
|
||||
|
||||
currentRoom.value = {
|
||||
room_id: roomData.room_id,
|
||||
short_code: roomData.short_code,
|
||||
participant_count: roomData.participant_count,
|
||||
participant_id: roomData.participant_id,
|
||||
joined_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
addToHistory(currentRoom.value)
|
||||
globalStore.addNotification('Joined room successfully', 'success', 3000)
|
||||
|
||||
return { success: true, room: currentRoom.value }
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
const errorMessage = error.response?.data?.error || 'Failed to join room'
|
||||
globalStore.addNotification(errorMessage, 'error', 5000)
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isJoiningRoom.value = false
|
||||
globalStore.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const leaveRoom = async (roomId) => {
|
||||
try {
|
||||
isLeavingRoom.value = true
|
||||
|
||||
if (currentRoom.value && currentRoom.value.room_id === roomId) {
|
||||
await apiService.leaveRoom(roomId)
|
||||
|
||||
// Clear current room and participants
|
||||
currentRoom.value = null
|
||||
roomParticipants.value = []
|
||||
|
||||
globalStore.addNotification('Left room', 'info', 3000)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Room not found' }
|
||||
} catch (error) {
|
||||
console.error('Failed to leave room:', error)
|
||||
const errorMessage = error.response?.data?.error || 'Failed to leave room'
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
isLeavingRoom.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getRoomInfo = async (roomId) => {
|
||||
try {
|
||||
const response = await apiService.getRoomInfo(roomId)
|
||||
return { success: true, room: response.data }
|
||||
} catch (error) {
|
||||
console.error('Failed to get room info:', error)
|
||||
const errorMessage = error.response?.data?.error || 'Room not found'
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRoom = async (roomId) => {
|
||||
try {
|
||||
await apiService.deleteRoom(roomId)
|
||||
|
||||
if (currentRoom.value && currentRoom.value.room_id === roomId) {
|
||||
currentRoom.value = null
|
||||
roomParticipants.value = []
|
||||
}
|
||||
|
||||
globalStore.addNotification('Room deleted', 'info', 3000)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to delete room:', error)
|
||||
const errorMessage = error.response?.data?.error || 'Failed to delete room'
|
||||
globalStore.addNotification(errorMessage, 'error', 5000)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
const updateParticipants = (participants) => {
|
||||
roomParticipants.value = participants || []
|
||||
}
|
||||
|
||||
const addParticipant = (participant) => {
|
||||
const existingIndex = roomParticipants.value.findIndex((p) => p.id === participant.id)
|
||||
if (existingIndex === -1) {
|
||||
roomParticipants.value.push(participant)
|
||||
} else {
|
||||
// Update existing participant
|
||||
roomParticipants.value[existingIndex] = {
|
||||
...roomParticipants.value[existingIndex],
|
||||
...participant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeParticipant = (participantId) => {
|
||||
roomParticipants.value = roomParticipants.value.filter((p) => p.id !== participantId)
|
||||
}
|
||||
|
||||
const addToHistory = (roomData) => {
|
||||
const historyEntry = {
|
||||
room_id: roomData.room_id,
|
||||
short_code: roomData.short_code,
|
||||
joined_at: roomData.joined_at || new Date().toISOString(),
|
||||
room_url: roomData.room_url || `${window.location.origin}/join/${roomData.short_code}`,
|
||||
duration: null,
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
// Remove existing entry if it exists
|
||||
roomHistory.value = roomHistory.value.filter((entry) => entry.room_id !== roomData.room_id)
|
||||
|
||||
// Add to beginning of array
|
||||
roomHistory.value.unshift(historyEntry)
|
||||
|
||||
// Keep only last 20 rooms
|
||||
roomHistory.value = roomHistory.value.slice(0, 20)
|
||||
|
||||
// Save to localStorage
|
||||
saveHistoryToStorage()
|
||||
}
|
||||
|
||||
const updateHistoryEntry = (roomId, updates) => {
|
||||
const entryIndex = roomHistory.value.findIndex((entry) => entry.room_id === roomId)
|
||||
if (entryIndex !== -1) {
|
||||
roomHistory.value[entryIndex] = { ...roomHistory.value[entryIndex], ...updates }
|
||||
saveHistoryToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const loadHistory = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('videocall_room_history')
|
||||
if (saved) {
|
||||
const parsedHistory = JSON.parse(saved)
|
||||
|
||||
// Validate and clean history data
|
||||
roomHistory.value = parsedHistory
|
||||
.filter((entry) => entry.room_id && entry.short_code) // Filter invalid entries
|
||||
.map((entry) => ({
|
||||
room_id: entry.room_id,
|
||||
short_code: entry.short_code,
|
||||
joined_at: entry.joined_at || new Date().toISOString(),
|
||||
room_url: entry.room_url || `${window.location.origin}/join/${entry.short_code}`,
|
||||
duration: entry.duration || null,
|
||||
status: entry.status || 'completed',
|
||||
}))
|
||||
.slice(0, 20) // Keep only latest 20
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load room history from localStorage:', error)
|
||||
roomHistory.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const saveHistoryToStorage = () => {
|
||||
try {
|
||||
localStorage.setItem('videocall_room_history', JSON.stringify(roomHistory.value))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save room history to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearHistory = () => {
|
||||
roomHistory.value = []
|
||||
try {
|
||||
localStorage.removeItem('videocall_room_history')
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear room history from localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRecentRooms = (limit = 5) => {
|
||||
return roomHistory.value.filter((room) => room.short_code && room.room_id).slice(0, limit)
|
||||
}
|
||||
|
||||
const searchHistory = (query) => {
|
||||
if (!query || query.trim() === '') {
|
||||
return roomHistory.value
|
||||
}
|
||||
|
||||
const searchTerm = query.toLowerCase().trim()
|
||||
return roomHistory.value.filter(
|
||||
(room) =>
|
||||
room.short_code.toLowerCase().includes(searchTerm) ||
|
||||
room.room_id.toLowerCase().includes(searchTerm),
|
||||
)
|
||||
}
|
||||
|
||||
const getRoomByCode = async (shortCode) => {
|
||||
try {
|
||||
// First try to find in current room
|
||||
if (currentRoom.value && currentRoom.value.short_code === shortCode) {
|
||||
return { success: true, room: currentRoom.value }
|
||||
}
|
||||
|
||||
// Then try to join/get room info
|
||||
const result = await joinRoom(shortCode)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to get room by code:', error)
|
||||
return { success: false, error: 'Room not found' }
|
||||
}
|
||||
}
|
||||
|
||||
const validateRoomCode = (code) => {
|
||||
// Room codes should be 6-8 alphanumeric characters
|
||||
const codeRegex = /^[A-Z0-9]{6,8}$/
|
||||
return codeRegex.test(code.toUpperCase())
|
||||
}
|
||||
|
||||
const validateRoomUrl = (url) => {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
return urlObj.pathname.includes('/join/') || urlObj.pathname.includes('/call/')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
currentRoom.value = null
|
||||
roomParticipants.value = []
|
||||
isCreatingRoom.value = false
|
||||
isJoiningRoom.value = false
|
||||
isLeavingRoom.value = false
|
||||
}
|
||||
|
||||
// Auto-save history when it changes
|
||||
const startHistoryAutoSave = () => {
|
||||
// Save history every 30 seconds if there are changes
|
||||
setInterval(() => {
|
||||
if (roomHistory.value.length > 0) {
|
||||
saveHistoryToStorage()
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
currentRoom,
|
||||
roomHistory,
|
||||
isCreatingRoom,
|
||||
isJoiningRoom,
|
||||
isLeavingRoom,
|
||||
roomParticipants,
|
||||
|
||||
// Computed
|
||||
hasActiveRoom,
|
||||
currentRoomId,
|
||||
currentRoomCode,
|
||||
participantCount,
|
||||
canJoinRoom,
|
||||
|
||||
// Actions
|
||||
createRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
getRoomInfo,
|
||||
deleteRoom,
|
||||
updateParticipants,
|
||||
addParticipant,
|
||||
removeParticipant,
|
||||
addToHistory,
|
||||
updateHistoryEntry,
|
||||
loadHistory,
|
||||
saveHistoryToStorage,
|
||||
clearHistory,
|
||||
getRecentRooms,
|
||||
searchHistory,
|
||||
getRoomByCode,
|
||||
validateRoomCode,
|
||||
validateRoomUrl,
|
||||
cleanup,
|
||||
startHistoryAutoSave,
|
||||
}
|
||||
})
|
||||
446
videocall-frontend/src/stores/webrtc.js
Normal file
446
videocall-frontend/src/stores/webrtc.js
Normal file
@@ -0,0 +1,446 @@
|
||||
// src/stores/webrtc.js - WebRTC and media state management
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGlobalStore } from './global'
|
||||
|
||||
export const useWebRTCStore = defineStore('webrtc', () => {
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
// State
|
||||
const localStream = ref(null)
|
||||
const remoteStream = ref(null)
|
||||
const peerConnection = ref(null)
|
||||
const websocket = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const isVideoEnabled = ref(true)
|
||||
const isAudioEnabled = ref(true)
|
||||
const connectionState = ref('new') // new, connecting, connected, disconnected, failed
|
||||
const remoteParticipants = ref([])
|
||||
const localParticipantId = ref(null)
|
||||
|
||||
// Media constraints
|
||||
const mediaConstraints = ref({
|
||||
video: {
|
||||
width: { ideal: 1280, max: 1920 },
|
||||
height: { ideal: 720, max: 1080 },
|
||||
frameRate: { ideal: 30, max: 60 },
|
||||
},
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Computed
|
||||
const hasLocalVideo = computed(() => localStream.value !== null)
|
||||
const hasRemoteVideo = computed(() => remoteStream.value !== null)
|
||||
const isCallActive = computed(
|
||||
() => isConnected.value && (hasLocalVideo.value || hasRemoteVideo.value),
|
||||
)
|
||||
|
||||
// WebRTC configuration
|
||||
const rtcConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
// Add TURN servers here for production
|
||||
],
|
||||
iceCandidatePoolSize: 10,
|
||||
}
|
||||
|
||||
// Actions
|
||||
const initializeLocalMedia = async () => {
|
||||
try {
|
||||
globalStore.setLoading(true, 'Accessing camera and microphone...')
|
||||
|
||||
localStream.value = await navigator.mediaDevices.getUserMedia(mediaConstraints.value)
|
||||
|
||||
// Set initial media states based on stream tracks
|
||||
const videoTrack = localStream.value.getVideoTracks()[0]
|
||||
const audioTrack = localStream.value.getAudioTracks()[0]
|
||||
|
||||
if (videoTrack) {
|
||||
isVideoEnabled.value = videoTrack.enabled
|
||||
}
|
||||
if (audioTrack) {
|
||||
isAudioEnabled.value = audioTrack.enabled
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
let errorMessage = 'Failed to access camera or microphone'
|
||||
|
||||
if (error.name === 'NotAllowedError') {
|
||||
errorMessage =
|
||||
'Camera and microphone access denied. Please allow permissions and try again.'
|
||||
} else if (error.name === 'NotFoundError') {
|
||||
errorMessage = 'No camera or microphone found on this device.'
|
||||
} else if (error.name === 'NotReadableError') {
|
||||
errorMessage = 'Camera or microphone is already in use by another application.'
|
||||
}
|
||||
|
||||
globalStore.addNotification(errorMessage, 'error', 8000)
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
globalStore.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createPeerConnection = () => {
|
||||
try {
|
||||
peerConnection.value = new RTCPeerConnection(rtcConfiguration)
|
||||
|
||||
// Add local stream tracks to peer connection
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => {
|
||||
peerConnection.value.addTrack(track, localStream.value)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle remote stream
|
||||
peerConnection.value.ontrack = (event) => {
|
||||
console.log('Received remote track:', event)
|
||||
remoteStream.value = event.streams[0]
|
||||
}
|
||||
|
||||
// Handle ICE candidates
|
||||
peerConnection.value.onicecandidate = (event) => {
|
||||
if (event.candidate && websocket.value) {
|
||||
sendWebSocketMessage({
|
||||
type: 'ice_candidate',
|
||||
candidate: event.candidate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle connection state changes
|
||||
peerConnection.value.onconnectionstatechange = () => {
|
||||
connectionState.value = peerConnection.value.connectionState
|
||||
console.log('Connection state:', connectionState.value)
|
||||
|
||||
if (connectionState.value === 'connected') {
|
||||
isConnected.value = true
|
||||
globalStore.addNotification('Video call connected', 'success', 3000)
|
||||
} else if (connectionState.value === 'disconnected' || connectionState.value === 'failed') {
|
||||
isConnected.value = false
|
||||
if (connectionState.value === 'failed') {
|
||||
globalStore.addNotification('Call connection failed', 'error', 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Failed to create peer connection:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = (roomId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// WebSocket должен подключаться к бэкенду (порт 8000), а не к фронтенду
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsHost = import.meta.env.VITE_WS_HOST || window.location.host
|
||||
const wsUrl = `${protocol}//${wsHost}/ws/room/${roomId}/`
|
||||
|
||||
console.log('Connecting to WebSocket:', wsUrl)
|
||||
websocket.value = new WebSocket(wsUrl)
|
||||
|
||||
websocket.value.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
resolve()
|
||||
}
|
||||
|
||||
websocket.value.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
await handleWebSocketMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to handle WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
websocket.value.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason)
|
||||
isConnected.value = false
|
||||
|
||||
if (event.code !== 1000) {
|
||||
// Not a normal closure
|
||||
globalStore.addNotification('Connection lost', 'error', 5000)
|
||||
}
|
||||
}
|
||||
|
||||
websocket.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
// Set timeout for connection
|
||||
setTimeout(() => {
|
||||
if (websocket.value && websocket.value.readyState !== WebSocket.OPEN) {
|
||||
websocket.value.close()
|
||||
reject(new Error('WebSocket connection timeout'))
|
||||
}
|
||||
}, 10000) // 10 second timeout
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = async (data) => {
|
||||
console.log('Received WebSocket message:', data.type)
|
||||
|
||||
switch (data.type) {
|
||||
case 'user_joined':
|
||||
handleUserJoined(data)
|
||||
break
|
||||
|
||||
case 'user_left':
|
||||
handleUserLeft(data)
|
||||
break
|
||||
|
||||
case 'webrtc_offer':
|
||||
await handleWebRTCOffer(data)
|
||||
break
|
||||
|
||||
case 'webrtc_answer':
|
||||
await handleWebRTCAnswer(data)
|
||||
break
|
||||
|
||||
case 'ice_candidate':
|
||||
await handleICECandidate(data)
|
||||
break
|
||||
|
||||
case 'media_state_update':
|
||||
handleMediaStateUpdate(data)
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// Handle ping response
|
||||
break
|
||||
|
||||
case 'error':
|
||||
globalStore.addNotification(data.message, 'error', 5000)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserJoined = (data) => {
|
||||
const participantId = data.participant_id
|
||||
|
||||
if (!remoteParticipants.value.find((p) => p.id === participantId)) {
|
||||
remoteParticipants.value.push({
|
||||
id: participantId,
|
||||
joined_at: data.timestamp,
|
||||
stream: null,
|
||||
})
|
||||
}
|
||||
|
||||
globalStore.addNotification('Someone joined the call', 'info', 3000)
|
||||
|
||||
// If we are already in the room, send an offer to the new participant
|
||||
if (peerConnection.value && localStream.value) {
|
||||
createOffer()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserLeft = (data) => {
|
||||
const participantId = data.participant_id
|
||||
|
||||
remoteParticipants.value = remoteParticipants.value.filter((p) => p.id !== participantId)
|
||||
|
||||
globalStore.addNotification('Someone left the call', 'info', 3000)
|
||||
|
||||
// Clear remote stream if this was the connected peer
|
||||
if (remoteStream.value) {
|
||||
remoteStream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebRTCOffer = async (data) => {
|
||||
try {
|
||||
if (!peerConnection.value) {
|
||||
createPeerConnection()
|
||||
}
|
||||
|
||||
await peerConnection.value.setRemoteDescription(new RTCSessionDescription(data.offer))
|
||||
const answer = await peerConnection.value.createAnswer()
|
||||
await peerConnection.value.setLocalDescription(answer)
|
||||
|
||||
sendWebSocketMessage({
|
||||
type: 'answer',
|
||||
answer: answer,
|
||||
target: data.sender,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to handle WebRTC offer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebRTCAnswer = async (data) => {
|
||||
try {
|
||||
await peerConnection.value.setRemoteDescription(new RTCSessionDescription(data.answer))
|
||||
} catch (error) {
|
||||
console.error('Failed to handle WebRTC answer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleICECandidate = async (data) => {
|
||||
try {
|
||||
await peerConnection.value.addIceCandidate(new RTCIceCandidate(data.candidate))
|
||||
} catch (error) {
|
||||
console.error('Failed to handle ICE candidate:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMediaStateUpdate = (data) => {
|
||||
const participant = remoteParticipants.value.find((p) => p.id === data.participant_id)
|
||||
if (participant) {
|
||||
participant.mediaState = data.state
|
||||
}
|
||||
}
|
||||
|
||||
const createOffer = async () => {
|
||||
try {
|
||||
if (!peerConnection.value) {
|
||||
createPeerConnection()
|
||||
}
|
||||
|
||||
const offer = await peerConnection.value.createOffer()
|
||||
await peerConnection.value.setLocalDescription(offer)
|
||||
|
||||
sendWebSocketMessage({
|
||||
type: 'offer',
|
||||
offer: offer,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to create offer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendWebSocketMessage = (message) => {
|
||||
if (websocket.value && websocket.value.readyState === WebSocket.OPEN) {
|
||||
websocket.value.send(JSON.stringify(message))
|
||||
} else {
|
||||
console.warn('WebSocket not connected, message not sent:', message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleVideo = () => {
|
||||
if (localStream.value) {
|
||||
const videoTrack = localStream.value.getVideoTracks()[0]
|
||||
if (videoTrack) {
|
||||
videoTrack.enabled = !videoTrack.enabled
|
||||
isVideoEnabled.value = videoTrack.enabled
|
||||
|
||||
// Notify other participants
|
||||
sendWebSocketMessage({
|
||||
type: 'media_state',
|
||||
state: {
|
||||
video: isVideoEnabled.value,
|
||||
audio: isAudioEnabled.value,
|
||||
},
|
||||
})
|
||||
|
||||
globalStore.addNotification(
|
||||
isVideoEnabled.value ? 'Camera turned on' : 'Camera turned off',
|
||||
'info',
|
||||
2000,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAudio = () => {
|
||||
if (localStream.value) {
|
||||
const audioTrack = localStream.value.getAudioTracks()[0]
|
||||
if (audioTrack) {
|
||||
audioTrack.enabled = !audioTrack.enabled
|
||||
isAudioEnabled.value = audioTrack.enabled
|
||||
|
||||
// Notify other participants
|
||||
sendWebSocketMessage({
|
||||
type: 'media_state',
|
||||
state: {
|
||||
video: isVideoEnabled.value,
|
||||
audio: isAudioEnabled.value,
|
||||
},
|
||||
})
|
||||
|
||||
globalStore.addNotification(
|
||||
isAudioEnabled.value ? 'Microphone turned on' : 'Microphone turned off',
|
||||
'info',
|
||||
2000,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const endCall = async () => {
|
||||
try {
|
||||
// Close peer connection
|
||||
if (peerConnection.value) {
|
||||
peerConnection.value.close()
|
||||
peerConnection.value = null
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (websocket.value) {
|
||||
websocket.value.close(1000, 'Call ended') // Normal closure
|
||||
websocket.value = null
|
||||
}
|
||||
|
||||
// Stop local media tracks
|
||||
if (localStream.value) {
|
||||
localStream.value.getTracks().forEach((track) => track.stop())
|
||||
localStream.value = null
|
||||
}
|
||||
|
||||
// Clear remote stream
|
||||
remoteStream.value = null
|
||||
|
||||
// Reset state
|
||||
isConnected.value = false
|
||||
connectionState.value = 'new'
|
||||
remoteParticipants.value = []
|
||||
|
||||
console.log('Call ended successfully')
|
||||
} catch (error) {
|
||||
console.error('Failed to end call:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
localStream,
|
||||
remoteStream,
|
||||
peerConnection,
|
||||
websocket,
|
||||
isConnected,
|
||||
isVideoEnabled,
|
||||
isAudioEnabled,
|
||||
connectionState,
|
||||
remoteParticipants,
|
||||
localParticipantId,
|
||||
mediaConstraints,
|
||||
|
||||
// Computed
|
||||
hasLocalVideo,
|
||||
hasRemoteVideo,
|
||||
isCallActive,
|
||||
|
||||
// Actions
|
||||
initializeLocalMedia,
|
||||
createPeerConnection,
|
||||
connectWebSocket,
|
||||
createOffer,
|
||||
sendWebSocketMessage,
|
||||
toggleVideo,
|
||||
toggleAudio,
|
||||
endCall,
|
||||
}
|
||||
})
|
||||
|
||||
99
videocall-frontend/src/style.css
Normal file
99
videocall-frontend/src/style.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/* Base styles */
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* Custom component styles */
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-green-500 hover:bg-green-600 text-white font-medium py-2 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white font-medium py-2 px-4 rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-opacity-50;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
@apply p-3 rounded-full shadow-lg transition-all duration-200 hover:scale-105 active:scale-95;
|
||||
}
|
||||
|
||||
.control-button-active {
|
||||
@apply bg-green-500 hover:bg-green-600 text-white;
|
||||
}
|
||||
|
||||
.control-button-inactive {
|
||||
@apply bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.control-button-danger {
|
||||
@apply bg-red-500 hover:bg-red-600 text-white;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.control-button {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.control-button svg {
|
||||
@apply w-5 h-5;
|
||||
}
|
||||
}
|
||||
15
videocall-frontend/src/views/AboutView.vue
Normal file
15
videocall-frontend/src/views/AboutView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
videocall-frontend/src/views/HomeView.vue
Normal file
9
videocall-frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script setup>
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
9
videocall-frontend/tailwind.config.js
Normal file
9
videocall-frontend/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
108
videocall-frontend/vite.config.js
Normal file
108
videocall-frontend/vite.config.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\./i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
|
||||
manifest: {
|
||||
name: 'Video Call App',
|
||||
short_name: 'VideoCall',
|
||||
description: 'Secure video calling without registration',
|
||||
theme_color: '#00C853',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
categories: ['communication', 'productivity'],
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'Create Room',
|
||||
short_name: 'Create',
|
||||
description: 'Start a new video call',
|
||||
url: '/?action=create',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Join Room',
|
||||
short_name: 'Join',
|
||||
description: 'Join an existing video call',
|
||||
url: '/?action=join',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true, // Enable PWA in development
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
sourcemap: true,
|
||||
},
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: false,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user