feat: Add real-time quiz lobby functionality
- Implement WebSocket-based lobby system for quiz participants - Add lobby page with real-time participant updates - Create LobbyConsumer for WebSocket communication - Add routing configuration for WebSocket connections - Update requirements with WebSocket dependencies - Add development and production server documentation This change enables real-time quiz lobbies where participants can join and wait for the quiz to start, with instant updates for all connected users.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,4 +5,5 @@ tailwindcss.exe
|
|||||||
t-style.css
|
t-style.css
|
||||||
dump.rdb
|
dump.rdb
|
||||||
media
|
media
|
||||||
node_modules
|
node_modules
|
||||||
|
staticfiles
|
||||||
@@ -8,9 +8,21 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from channels.auth import AuthMiddlewareStack
|
||||||
|
import django
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
django.setup()
|
||||||
|
|
||||||
application = get_asgi_application()
|
from play.routing import websocket_urlpatterns
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter({
|
||||||
|
'http': get_asgi_application(),
|
||||||
|
'websocket': AuthMiddlewareStack(
|
||||||
|
URLRouter(
|
||||||
|
websocket_urlpatterns
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|||||||
@@ -42,10 +42,12 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
'channels',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
@@ -73,6 +75,13 @@ TEMPLATES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'core.wsgi.application'
|
WSGI_APPLICATION = 'core.wsgi.application'
|
||||||
|
ASGI_APPLICATION = 'core.asgi.application'
|
||||||
|
|
||||||
|
CHANNEL_LAYERS = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'channels.layers.InMemoryChannelLayer'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
@@ -85,6 +94,12 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
STORAGES = {
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||||
@@ -121,6 +136,7 @@ USE_TZ = True
|
|||||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
|
|||||||
117
django/play/consumers.py
Normal file
117
django/play/consumers.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from .models import QuizGame, QuizGameParticipant
|
||||||
|
|
||||||
|
class LobbyConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
self.join_code = self.scope['url_route']['kwargs']['join_code']
|
||||||
|
self.room_group_name = f'game_{self.join_code}'
|
||||||
|
self.heartbeat_task = None
|
||||||
|
|
||||||
|
# Join room group
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
self.room_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
# Start heartbeat check
|
||||||
|
self.heartbeat_task = asyncio.create_task(self.check_participants_heartbeat())
|
||||||
|
|
||||||
|
# Send current participants list
|
||||||
|
participants = await self.get_participants()
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'participant_list',
|
||||||
|
'participants': participants
|
||||||
|
}))
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
# Cancel heartbeat task
|
||||||
|
if self.heartbeat_task:
|
||||||
|
self.heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Leave room group
|
||||||
|
await self.channel_layer.group_discard(
|
||||||
|
self.room_group_name,
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
text_data_json = json.loads(text_data)
|
||||||
|
message_type = text_data_json['type']
|
||||||
|
|
||||||
|
if message_type == 'heartbeat':
|
||||||
|
# Update participant's last activity timestamp
|
||||||
|
participant_id = text_data_json.get('participant_id')
|
||||||
|
if participant_id:
|
||||||
|
await self._update_participant_heartbeat(participant_id)
|
||||||
|
|
||||||
|
elif message_type == 'update_participants':
|
||||||
|
participants = await self.get_participants()
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'participant_list_update',
|
||||||
|
'participants': participants
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def participant_list_update(self, event):
|
||||||
|
participants = event['participants']
|
||||||
|
await self.send(text_data=json.dumps({
|
||||||
|
'type': 'participant_list',
|
||||||
|
'participants': participants
|
||||||
|
}))
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def get_participants(self):
|
||||||
|
game = QuizGame.objects.get(join_code=self.join_code)
|
||||||
|
participants = QuizGameParticipant.objects.filter(quiz_game=game)
|
||||||
|
return [{'id': str(p.participant_id), 'name': p.display_name} for p in participants]
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _update_participant_heartbeat(self, participant_id):
|
||||||
|
try:
|
||||||
|
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||||
|
participant.save() # This will update last_heartbeat due to auto_now=True
|
||||||
|
except QuizGameParticipant.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _remove_inactive_participants(self):
|
||||||
|
# Remove participants who haven't sent a heartbeat in the last 30 seconds
|
||||||
|
timeout = timezone.now() - timedelta(seconds=30)
|
||||||
|
quiz_game = QuizGame.objects.get(join_code=self.join_code)
|
||||||
|
QuizGameParticipant.objects.filter(
|
||||||
|
quiz_game=quiz_game,
|
||||||
|
last_heartbeat__lt=timeout
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
async def check_participants_heartbeat(self):
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(10) # Check every 10 seconds
|
||||||
|
await self._remove_inactive_participants()
|
||||||
|
|
||||||
|
# Send updated participant list
|
||||||
|
participants = await self.get_participants()
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.room_group_name,
|
||||||
|
{
|
||||||
|
'type': 'participant_list_update',
|
||||||
|
'participants': participants
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error in heartbeat check: {e}')
|
||||||
|
await asyncio.sleep(10) # Wait before retrying
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-04-05 10:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('play', '0003_quizgameparticipant_participant_id_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='quizgame',
|
||||||
|
name='host_user',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='quizgame',
|
||||||
|
name='host_id',
|
||||||
|
field=models.CharField(default=None, max_length=200, unique=True),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-04-05 12:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('play', '0004_remove_quizgame_host_user_quizgame_host_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='quizgameparticipant',
|
||||||
|
name='last_heartbeat',
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,13 +6,13 @@ import random, string
|
|||||||
|
|
||||||
# Create your models here.
|
# Create your models here.
|
||||||
class QuizGame(models.Model):
|
class QuizGame(models.Model):
|
||||||
host_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='host_user')
|
host_id = models.CharField(max_length=200, unique=True)
|
||||||
join_code = models.CharField(max_length=6, unique=True, blank=True)
|
join_code = models.CharField(max_length=6, unique=True, blank=True)
|
||||||
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
|
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.join_code:
|
if not self.join_code:
|
||||||
self.join_code = self.generate_unique_code()
|
self.join_code = self.generate_unique_code()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def generate_unique_code(self):
|
def generate_unique_code(self):
|
||||||
@@ -21,12 +21,19 @@ class QuizGame(models.Model):
|
|||||||
if not QuizGame.objects.filter(join_code=new_code).exists():
|
if not QuizGame.objects.filter(join_code=new_code).exists():
|
||||||
return new_code
|
return new_code
|
||||||
|
|
||||||
|
def generate_unique_id(self):
|
||||||
|
for i in range(10):
|
||||||
|
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
|
||||||
|
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
|
||||||
|
return new_id
|
||||||
|
|
||||||
class QuizGameParticipant(models.Model):
|
class QuizGameParticipant(models.Model):
|
||||||
participant_id = models.CharField(max_length=200, unique=True)
|
participant_id = models.CharField(max_length=200, unique=True)
|
||||||
display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
|
display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
|
||||||
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant")
|
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant")
|
||||||
score = models.IntegerField(verbose_name="Punkte", default=0)
|
score = models.IntegerField(verbose_name="Punkte", default=0)
|
||||||
avatar = models.CharField(max_length=200, blank=True, default="")
|
avatar = models.CharField(max_length=200, blank=True, default="")
|
||||||
|
last_heartbeat = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.participant_id:
|
if not self.participant_id:
|
||||||
|
|||||||
6
django/play/routing.py
Normal file
6
django/play/routing.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r'ws/game/(?P<join_code>\w+)/$', consumers.LobbyConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -4,9 +4,10 @@ from . import views
|
|||||||
app_name = 'play'
|
app_name = 'play'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('game/new/<int:quiz_id>', views.create_game, name='create_game'),
|
||||||
path('game/<str:join_code>', views.lobby, name='lobby'),
|
path('game/<str:join_code>', views.lobby, name='lobby'),
|
||||||
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
|
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
|
||||||
path('join', views.join_game, name='join_game'),
|
path('join', views.join_game, name='join_game'),
|
||||||
path('game/<str:join_code>/<int:question_id>', views.question, name='question'),
|
path('game/<str:join_code>/<int:question_id>', views.question, name='question'),
|
||||||
path('game/<str:join_code>/<int:question_id>/participant', views.question_participant, name='question_participant'),
|
path('game/<str:join_code>/<int:question_id>/participant', views.question_participant, name='question_participant'),
|
||||||
]
|
]
|
||||||
@@ -2,34 +2,52 @@ from django.shortcuts import render
|
|||||||
from django.shortcuts import render, redirect, get_object_or_404, reverse
|
from django.shortcuts import render, redirect, get_object_or_404, reverse
|
||||||
from play.models import QuizGame, QuizGameParticipant
|
from play.models import QuizGame, QuizGameParticipant
|
||||||
from library.models import QivipQuiz, QivipQuestion
|
from library.models import QivipQuiz, QivipQuestion
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect, HttpResponseNotFound
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
def lobby(request, join_code):
|
def lobby(request, join_code):
|
||||||
if not "participant_id" in request.COOKIES:
|
|
||||||
return redirect('play:create_participant', join_code=join_code)
|
|
||||||
participant_id = request.COOKIES['participant_id']
|
|
||||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||||
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
|
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
|
||||||
participant = get_object_or_404(QuizGameParticipant, participant_id=participant_id)
|
|
||||||
|
|
||||||
if not participant.quiz_game.join_code == join_code:
|
|
||||||
participant.quiz_game = quiz_game
|
|
||||||
participant.save()
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'debug': "test",
|
|
||||||
'participant': participant,
|
|
||||||
'quiz': quiz,
|
'quiz': quiz,
|
||||||
|
'quiz_game': quiz_game,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if "host_id" in request.COOKIES:
|
||||||
|
host_id = request.COOKIES['host_id']
|
||||||
|
if host_id == quiz_game.host_id:
|
||||||
|
context['host_id'] = host_id
|
||||||
|
return render(request, 'play/lobby.html', context=context)
|
||||||
|
|
||||||
|
if not "participant_id" in request.COOKIES:
|
||||||
|
return redirect('play:create_participant', join_code=join_code)
|
||||||
|
|
||||||
|
participant_id = request.COOKIES['participant_id']
|
||||||
|
try:
|
||||||
|
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||||
|
if not participant.quiz_game.join_code == join_code: # Teilnehmer dem richtigen Spiel hinzufügen
|
||||||
|
participant.quiz_game = quiz_game
|
||||||
|
participant.save()
|
||||||
|
except QuizGameParticipant.DoesNotExist:
|
||||||
|
return redirect('play:create_participant', join_code=join_code)
|
||||||
|
|
||||||
|
context['participant'] = participant
|
||||||
return render(request, 'play/lobby.html', context=context)
|
return render(request, 'play/lobby.html', context=context)
|
||||||
|
|
||||||
def create_participant(request, join_code):
|
def create_participant(request, join_code):
|
||||||
if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt
|
if "participant_id" in request.COOKIES:
|
||||||
return redirect('play:lobby', join_code=join_code)
|
# Prüfe ob der Teilnehmer noch existiert
|
||||||
|
participant_id = request.COOKIES['participant_id']
|
||||||
|
try:
|
||||||
|
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||||
|
return redirect('play:lobby', join_code=join_code)
|
||||||
|
except QuizGameParticipant.DoesNotExist:
|
||||||
|
# Cookie löschen wenn Teilnehmer nicht mehr existiert
|
||||||
|
response = HttpResponseRedirect(request.path)
|
||||||
|
response.delete_cookie('participant_id')
|
||||||
|
return response
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
display_name = request.POST.get('display_name')
|
display_name = request.POST.get('display_name')
|
||||||
join_code = request.POST.get('join_code')
|
join_code = request.POST.get('join_code')
|
||||||
@@ -50,6 +68,22 @@ def create_participant(request, join_code):
|
|||||||
|
|
||||||
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
|
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
|
||||||
|
|
||||||
|
def create_game(request, quiz_id):
|
||||||
|
try:
|
||||||
|
quiz = QivipQuiz.objects.get(id=quiz_id)
|
||||||
|
game = QuizGame()
|
||||||
|
game.quiz_id = quiz
|
||||||
|
game.host_id = game.generate_unique_id()
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': game.join_code}))
|
||||||
|
response.set_cookie('host_id', game.host_id, max_age=3600)
|
||||||
|
|
||||||
|
return response
|
||||||
|
except QivipQuiz.DoesNotExist:
|
||||||
|
return HttpResponseNotFound("Quiz nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
def join_game(request):
|
def join_game(request):
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
join_code = request.POST.get('game_code')
|
join_code = request.POST.get('game_code')
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
<!-- Buttons bleiben am unteren Rand -->
|
<!-- Buttons bleiben am unteren Rand -->
|
||||||
<div class="flex justify-between items-center gap-2 ">
|
<div class="flex justify-between items-center gap-2 ">
|
||||||
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
<a href="{% url 'play:create_game' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold drop-shadow-lg custom-outline">
|
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold drop-shadow-lg custom-outline">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
|
|
||||||
<!-- Buttons bleiben am unteren Rand -->
|
<!-- Buttons bleiben am unteren Rand -->
|
||||||
<div class="flex justify-between items-center gap-2 ">
|
<div class="flex justify-between items-center gap-2 ">
|
||||||
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
<a href="{% url 'play:create_game' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
||||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">
|
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not filter_other_quizzes and not filter_my_quizzes %}
|
{% if not filter_other_quizzes and not filter_my_quizzes %}
|
||||||
<div class="grid text-center font-bold items-center">Es wurde kein Quiz passendes gefunden!</div>
|
<div class="grid text-center font-bold items-center">Es wurde kein passendes Quiz gefunden!</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,73 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<h1>{{ quiz.name }}</h1>
|
<h3>{{ quiz.name }}</h3>
|
||||||
|
<h1 class="text-center text-4xl font-bold mb-4">{{ quiz_game.join_code }}</h1>
|
||||||
|
{% if participant %}
|
||||||
<div class="mt-4 mb-4 text-center">
|
<div class="mt-4 mb-4 text-center">
|
||||||
<h3>Sichtbar als <b>{{ participant.display_name }}</b></h3>
|
<h3>Sichtbar als <b>{{ participant.display_name }}</b></h3>
|
||||||
</div>
|
</div>
|
||||||
<div id="player-list">
|
{% endif %}
|
||||||
<ul>
|
<div class="mb-4">
|
||||||
<li>Noch keine Spieler gefunden.</li>
|
<h3 class="font-bold mb-2">Teilnehmer:</h3>
|
||||||
|
<ul id="participants-list" class="space-y-2">
|
||||||
|
<li class="text-gray-500">Warte auf Teilnehmer...</li>
|
||||||
</ul>
|
</ul>
|
||||||
{{debug}}
|
|
||||||
</div>
|
</div>
|
||||||
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit">Starten</button>
|
{% if host_id %}
|
||||||
|
<button id="start-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="button">Starten</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
<script>
|
<script>
|
||||||
// TODO: Websocket Verbindung aufbauen und Teilnehmerliste bei beitreten updaten
|
const joinCode = '{{ quiz_game.join_code }}';
|
||||||
|
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const lobbySocket = new WebSocket(
|
||||||
|
wsScheme + '://' + window.location.host + '/ws/game/' + joinCode + '/'
|
||||||
|
);
|
||||||
|
|
||||||
|
lobbySocket.onmessage = function(e) {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'participant_list' || data.type === 'participant_list_update') {
|
||||||
|
updateParticipantsList(data.participants);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lobbySocket.onclose = function(e) {
|
||||||
|
console.error('Lobby socket closed unexpectedly');
|
||||||
|
};
|
||||||
|
|
||||||
|
{% if participant %}
|
||||||
|
// Send heartbeat every 10 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
if (lobbySocket.readyState === WebSocket.OPEN) {
|
||||||
|
lobbySocket.send(JSON.stringify({
|
||||||
|
'type': 'heartbeat',
|
||||||
|
'participant_id': '{{ participant.participant_id }}'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
function updateParticipantsList(participants) {
|
||||||
|
const list = document.getElementById('participants-list');
|
||||||
|
if (participants.length === 0) {
|
||||||
|
list.innerHTML = '<li class="text-gray-500">Noch keine Teilnehmer...</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = participants
|
||||||
|
.map(p => `<li class="p-2 bg-gray-100 rounded">${p.name}</li>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request initial participants list
|
||||||
|
lobbySocket.onopen = function(e) {
|
||||||
|
lobbySocket.send(JSON.stringify({
|
||||||
|
'type': 'update_participants'
|
||||||
|
}));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user