This commit is contained in:
september
2025-09-03 06:51:24 -07:00
commit 54a81cbdcf
95 changed files with 17637 additions and 0 deletions

82
.gitignore vendored Normal file
View 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

0
README.md Normal file
View File

37
backend/Dockerfile Normal file
View 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
View File

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.authentication'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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'),
]

View 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
})

View File

180
backend/apps/core/admin.py Normal file
View 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 = 'Управление системой видеозвонков'

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.core'

View 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')],
},
),
]

View File

@@ -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'},
),
]

View File

View 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')}"

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
backend/apps/core/urls.py Normal file
View 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
View 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

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RoomsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.rooms'

View 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)

View 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

View 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()),
]

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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
View 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
View 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
View 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
View 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()

View File

View 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)
)
),
})

View 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'

View 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)

View 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
View 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
View 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
View 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;
}
}

View 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

View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
videocall-frontend/.gitignore vendored Normal file
View 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

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View 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;"]

View 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,
])

View 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>

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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"
}
]
}
]
}

View 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>

View 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;
}

View 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

View 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;
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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,
}
}

View 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
}

View 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 }

View 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
},
}

View 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')
},
}

View 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]
},
}

View 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(),
}
},
}

View 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)))
},
}

View 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 }
})

View 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,
}
})

View 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,
}
})

View 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,
}
})

View 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;
}
}

View 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>

View File

@@ -0,0 +1,9 @@
<script setup>
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
theme: {
extend: {},
},
plugins: [],
}

View 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,
},
})