Merge branch 'master' of edugit.org:enrichment-2024/qivip

This commit is contained in:
Juhn-Vitus Saß
2025-04-05 19:12:29 +02:00
18 changed files with 445 additions and 79 deletions

View File

@@ -47,16 +47,42 @@ Um das Projekt erfolgreich zu starten, folge diesen Schritten:
### Entwicklungsserver starten
Um den Server zu starten, verwende einen der folgenden Befehle:
Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne:
- Standardport (8000):
```sh
python3 core/manage.py runserver
```
- Bestimmten Port (z. B. 9000):
```sh
python3 core/manage.py runserver 9000
```
1. Aktiviere die virtuelle Umgebung:
```sh
source .venv/bin/activate # Linux
.venv\Scripts\activate # Windows
```
2. Starte den Daphne-Server:
```sh
# Im Verzeichnis 'django'
daphne -b 127.0.0.1 -p 8000 core.asgi:application
```
Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst:
```sh
python3 core/manage.py runserver
```
### Produktionsserver starten
Für den Produktivbetrieb verwenden wir Daphne als ASGI-Server, der sowohl HTTP als auch WebSocket-Verbindungen unterstützt:
1. Aktiviere die virtuelle Umgebung wie oben beschrieben
2. Sammle die statischen Dateien:
```sh
python3 core/manage.py collectstatic
```
3. Starte den Daphne-Server:
```sh
daphne -b 0.0.0.0 -p 8000 core.asgi:application
```
- `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces
- `-p 8000`: Port (anpassbar)
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden!
@@ -100,10 +126,10 @@ npx @tailwindcss/cli -i <INPUT-DATEI> -o <OUTPUT-DATEI> --watch --minify
```sh
# unter Linux mit Tailwind Binärdatei:
tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
npx tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
# unter Windows mit NPM (in bash mit '/' statt '\'):
npx @tailwindcss/cli -i .\static\css\t-input.css -o .\static\css\t-style.css --watch --minify
npx @tailwindcss/cli -i ./static/css/t-input.css -o ./static/css/t-style.css --watch --minify
```
## Issue - Merge Request - Merge

View File

@@ -8,9 +8,21 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
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')
django.setup()
application = get_asgi_application()
from play.routing import websocket_urlpatterns
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
websocket_urlpatterns
)
),
})

View File

@@ -42,10 +42,12 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -73,6 +75,13 @@ TEMPLATES = [
]
WSGI_APPLICATION = 'core.wsgi.application'
ASGI_APPLICATION = 'core.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer'
}
}
# Database
@@ -85,6 +94,12 @@ DATABASES = {
}
}
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Password validation
# 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/
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / 'static']
# Default primary key field type

View File

@@ -14,6 +14,8 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf.urls.static import static
from django.conf import settings
from django.contrib import admin # type: ignore
from django.urls import path, include
@@ -25,3 +27,6 @@ urlpatterns = [
path('library/', include('library.urls')),
path('components/', include('components.urls'))
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -16,6 +16,4 @@ urlpatterns = [
path('question/delete/<int:pk>/', views.delete_question, name='delete_question'),
path('detail/copy/<int:pk>/', views.copy_quiz, name='copy_quiz'),
]# Nur für die Entwicklungsumgebung
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
]

View File

@@ -130,8 +130,8 @@ def delete_quiz(request, pk):
def detail_quiz(request, pk):
#quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
quiz = get_object_or_404(QivipQuiz, pk=pk)
show_answers = request.GET.get('show_answers', 'false').lower() == 'true'
questions = QivipQuestion.objects.filter(quiz_id=quiz)
# Parse JSON data for each question
for question in questions:
if question.data:
@@ -139,7 +139,8 @@ def detail_quiz(request, pk):
context = {
'quiz': quiz,
'questions': questions
'questions': questions,
'detail':show_answers,
}
return render(request, 'library/detail_quiz.html', context)
"""

117
django/play/consumers.py Normal file
View 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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import random, string
# Create your models here.
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)
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
@@ -21,12 +21,19 @@ class QuizGame(models.Model):
if not QuizGame.objects.filter(join_code=new_code).exists():
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):
participant_id = models.CharField(max_length=200, unique=True)
display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant")
score = models.IntegerField(verbose_name="Punkte", default=0)
avatar = models.CharField(max_length=200, blank=True, default="")
last_heartbeat = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if not self.participant_id:

6
django/play/routing.py Normal file
View 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()),
]

View File

@@ -4,9 +4,10 @@ from . import views
app_name = 'play'
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>/participate', views.create_participant, name='create_participant'),
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>/participant', views.question_participant, name='question_participant'),
path('game/<str:join_code>/<int:question_id>/participant', views.question_participant, name='question_participant'),
]

View File

@@ -2,34 +2,52 @@ from django.shortcuts import render
from django.shortcuts import render, redirect, get_object_or_404, reverse
from play.models import QuizGame, QuizGameParticipant
from library.models import QivipQuiz, QivipQuestion
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect, HttpResponseNotFound
import json
# Create your views here.
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 = 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 = {
'debug': "test",
'participant': participant,
'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)
def create_participant(request, join_code):
if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt
return redirect('play:lobby', join_code=join_code)
if "participant_id" in request.COOKIES:
# 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':
display_name = request.POST.get('display_name')
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})
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):
if request.method == 'POST':
join_code = request.POST.get('game_code')

View File

@@ -2,7 +2,6 @@
{% block content %}
<div class="container mx-auto px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ quiz.name }}</h1>
@@ -28,7 +27,33 @@
{% if quiz.description %}
<p class="text-gray-600 mb-4">{{ quiz.description }}</p>
{% endif %}
<div class="flex justify-end mb-4">
{% if detail == True %}
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=false">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
&nbsp; Antworten verstecken
</div>
</a>
{% else %}
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=true">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
&nbsp; Antworten anzeigen
</div>
</a>
{% endif %}
</div>
<div class="bg-white rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md">
@@ -69,34 +94,41 @@
</span>
</div>
<div class="mt-2">
{% if detail %}
{% if question.data.type == 'multiple_choice' %}
<ul class="space-y-1 ">
{% for option in question.data.options %}
<li class="flex items-center text-sm">
{% if option.is_correct %}
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">{{ option.value }}</span>
{% else %}
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
</svg>
<span class="text-gray-600">{{ option.value }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
{% for option in question.data.options %}
{% if option.is_correct %}
<p class="text-sm">
<span class="text-gray-500">Richtige Antwort:</span>
<span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
</p>
{% endif %}
{% endfor %}
<ul class="space-y-1 ">
{% for option in question.data.options %}
<li class="flex items-center text-sm">
{% if option.is_correct %}
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">{{ option.value }}</span>
{% else %}
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
</svg>
<span class="text-gray-600">{{ option.value }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
{% for option in question.data.options %}
{% if option.is_correct %}
<p class="text-sm">
<span class="text-gray-500">Richtige Antwort:</span>
<span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
</p>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
</div>
</div>
@@ -130,6 +162,12 @@
{% endif %}
</div>
<div class="flex justify-end">
{% if quiz.base_quiz %}
<p class="text-gray-600 mt-2 ">Bearbeitung:<span class="font-bold"> {{ quiz.user_id}}</span></p>
{% endif %}
</div>
<div class="flex justify-end">
{% if quiz.base_quiz %}
<p class="text-gray-600 mt-2 ">Kopie von: <a href="{% url 'library:detail_quiz' quiz.base_quiz.id %}"><span class="font-bold">{{ quiz.base_quiz}}</span></a></p>

View File

@@ -5,7 +5,16 @@
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Speichern</button>
<div class="flex justify-center space-x-3">
<a href="{% if object.quiz_id %}{% url 'library:detail_quiz' object.quiz_id.id %}{% else %}{% url 'library:overview_quiz' %}{% endif %}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Abbrechen
</a>
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-white">
Speichern
</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -58,7 +58,7 @@
{% for quiz in filter_my_quizzes %}
<div class="swiper-slide">
<div class="relative h-80 bg-white bg-cover text-white w-full max-w-md rounded-lg p-4 shadow-md border-blue-600 border-2 rounded-xl flex flex-col justify-between"
{% if quiz.image %} style="background-image: url('http://127.0.0.1:8000/library{{ quiz.image.url }}');" {% endif %}>
{% if quiz.image %} style="background-image: url('http://127.0.0.1:8000/{{ quiz.image.url }}');" {% endif %}>
<div class="absolute inset-0 bg-gray-900/60 bg-opacity-50 rounded-lg"></div>
<h2 class=" break-words truncate text-white drop-shadow-lg custom-outline"> <a href="{% url 'library:detail_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
@@ -85,7 +85,7 @@
<!-- Buttons bleiben am unteren Rand -->
<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">
<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">
@@ -145,7 +145,7 @@
<!-- Buttons bleiben am unteren Rand -->
<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">
<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" />
@@ -176,7 +176,7 @@
{% endif %}
{% 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 %}

View File

@@ -2,20 +2,73 @@
{% block content %}
<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">
<h3>Sichtbar als <b>{{ participant.display_name }}</b></h3>
</div>
<div id="player-list">
<ul>
<li>Noch keine Spieler gefunden.</li>
{% endif %}
<div class="mb-4">
<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>
{{debug}}
</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>
{% endblock %}
{% block extra_js %}
<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>
{% endblock %}

View File

@@ -1,4 +1,6 @@
# Core dependencies
django~=5.1.4
channels~=4.2.0
daphne~=4.1.2
pillow~=11.1.0
channels~=4.2.0 # WebSocket support
daphne~=4.1.2 # ASGI server
pillow~=11.1.0 # Image handling
whitenoise~=6.6.0 # Static file serving