Compare commits

..

2 Commits

Author SHA1 Message Date
Juhn-Vitus Saß
0dc30f9cbc Merge branch 'master' into 44-zuruecksetz-funktion-fuer-teilnehmer 2025-04-21 19:58:22 +02:00
juhnsa
3df748dc96 Grundlegende Funktion 2025-04-07 17:19:54 +02:00
12 changed files with 236 additions and 169 deletions

View File

@@ -61,13 +61,6 @@ Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne:
daphne -b 127.0.0.1 -p 8000 core.asgi:application daphne -b 127.0.0.1 -p 8000 core.asgi:application
``` ```
3. Starte den REDIS Server
```sh
redis-server
```
> **Hinweis:** Der Redis Server muss, wenn er nicht auf dem selben Gerät unter Standardeinstellungen läuft, in der Datei play/consumers/lobby.py und core/settings.py entsprechend umkonfiguriert werden!
Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst: Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst:
```sh ```sh
python3 core/manage.py runserver python3 core/manage.py runserver
@@ -91,19 +84,6 @@ Für den Produktivbetrieb verwenden wir Daphne als ASGI-Server, der sowohl HTTP
- `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces - `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces
- `-p 8000`: Port (anpassbar) - `-p 8000`: Port (anpassbar)
4. Cronjob für Bereinigung der Spiele in der Datenbank
- Zum bearbeiten der Cronjobs:
```sh
crontab -e
```
- Dann `0 0 * * * /path/to/venv/bin/python /path/to/your_project/manage.py cleanup_games` einfügen, damit um Mitternacht alle Spiele gelöscht werden
5. Starte den REDIS Server
```sh
redis-server
```
> **Hinweis:** Der Redis Server muss, wenn er nicht auf dem selben Gerät unter Standardeinstellungen läuft, in der Datei play/consumers/lobby.py und core/settings.py entsprechend umkonfiguriert werden!
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden! Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden!
## Superuser erstellen ## Superuser erstellen

View File

@@ -79,14 +79,10 @@ WSGI_APPLICATION = 'core.wsgi.application'
ASGI_APPLICATION = 'core.asgi.application' ASGI_APPLICATION = 'core.asgi.application'
CHANNEL_LAYERS = { CHANNEL_LAYERS = {
"default": { 'default': {
"BACKEND": "channels_redis.core.RedisChannelLayer", 'BACKEND': 'channels.layers.InMemoryChannelLayer'
"CONFIG": { }
"hosts": [("127.0.0.1", 6379)],
},
},
} }
# Database # Database

View File

@@ -1,13 +0,0 @@
from django.core.management.base import BaseCommand
from play.models import QuizGame
from django.utils import timezone
class Command(BaseCommand):
help = 'Bereinigt beendete Spiele'
def handle(self, *args, **kwargs):
expired_games = QuizGame.objects.filter(
updated_at__lt=timezone.now() - timezone.timedelta(minutes=5))
deleted_count = expired_games.count()
expired_games.delete()
self.stdout.write(f'{deleted_count} alte Spiele gelöscht.')

View File

@@ -266,8 +266,6 @@ def copy_quiz(request, pk):
new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User
new_quiz.name += " - Kopie" # Optional: Name anpassen new_quiz.name += " - Kopie" # Optional: Name anpassen
new_quiz.base_quiz_id = pk new_quiz.base_quiz_id = pk
new_quiz.rating_count = 0
new_quiz.average_rating = 3.0
#new_quiz.creator = original_quiz.creator or request.user #new_quiz.creator = original_quiz.creator or request.user
new_quiz.save() # Speichern als neues Objekt new_quiz.save() # Speichern als neues Objekt
# Optional: Beschreibung anpassen # Optional: Beschreibung anpassen

View File

@@ -9,7 +9,75 @@ from datetime import timedelta
from play.models import QuizGame, QuizGameParticipant, QuizAnswer from play.models import QuizGame, QuizGameParticipant, QuizAnswer
# Dictionary to track inactive check tasks per game
game_check_tasks = {}
class GameConsumer(AsyncWebsocketConsumer): class GameConsumer(AsyncWebsocketConsumer):
@classmethod
async def start_inactive_check(cls, join_code, channel_layer):
"""Start the inactive player check for a game if not already running."""
if join_code not in game_check_tasks or game_check_tasks[join_code].done():
async def check_inactive_players():
game_group_name = f'game_{join_code}'
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
try:
game = await database_sync_to_async(QuizGame.objects.get)(join_code=join_code)
cutoff_time = timezone.now() - timedelta(minutes=1)
# Get active and inactive participants
all_participants = await database_sync_to_async(lambda: list(
game.participants.all().values('participant_id', 'display_name', 'last_heartbeat')
))()
# Konvertiere datetime zu ISO Format String
for p in all_participants:
if p['last_heartbeat']:
p['last_heartbeat'] = p['last_heartbeat'].isoformat()
active_participants = [p for p in all_participants if p['last_heartbeat'] and timezone.datetime.fromisoformat(p['last_heartbeat']) >= cutoff_time]
inactive_participants = [p for p in all_participants if not p['last_heartbeat'] or timezone.datetime.fromisoformat(p['last_heartbeat']) < cutoff_time]
# Remove inactive participants
if inactive_participants:
for p in inactive_participants:
# Benachrichtige andere über den gekickten Spieler
await channel_layer.group_send(
game_group_name,
{
'type': 'player_left',
'player_name': p['display_name'],
'was_kicked': True
}
)
# Lösche den inaktiven Teilnehmer
await database_sync_to_async(QuizGameParticipant.objects.filter(
participant_id=p['participant_id']
).delete)()
# Broadcast update to all clients
if active_participants or inactive_participants:
await channel_layer.group_send(
game_group_name,
{
'type': 'participant_list_update',
'participants': active_participants
}
)
except QuizGame.DoesNotExist:
break # Stop checking if game no longer exists
except Exception as e:
print(f'Error checking inactive players in game: {e}')
await asyncio.sleep(5) # Wait before retry
except asyncio.CancelledError:
break
except Exception as e:
print(f'Error in game inactive check loop: {e}')
await asyncio.sleep(5)
game_check_tasks[join_code] = asyncio.create_task(check_inactive_players())
async def connect(self): async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code'] self.join_code = self.scope['url_route']['kwargs']['join_code']
@@ -23,6 +91,9 @@ class GameConsumer(AsyncWebsocketConsumer):
await self.accept() await self.accept()
# Ensure inactive check is running for this game
await self.start_inactive_check(self.join_code, self.channel_layer)
async def disconnect(self, close_code): async def disconnect(self, close_code):
# Leave game group # Leave game group
await self.channel_layer.group_discard( await self.channel_layer.group_discard(
@@ -75,6 +146,21 @@ class GameConsumer(AsyncWebsocketConsumer):
pass pass
return return
if message_type == 'heartbeat':
participant_id = text_data_json.get('participant_id')
if participant_id:
await self.update_participant_heartbeat(participant_id)
# Update participants list after heartbeat
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
return
if message_type == 'submit_rating': if message_type == 'submit_rating':
participant_id = text_data_json['participant_id'] participant_id = text_data_json['participant_id']
rating = text_data_json['rating'] rating = text_data_json['rating']
@@ -164,6 +250,17 @@ class GameConsumer(AsyncWebsocketConsumer):
'redirect_url': event.get('redirect_url') 'redirect_url': event.get('redirect_url')
})) }))
async def update_participant_heartbeat(self, participant_id):
"""Update the last heartbeat timestamp for a participant."""
try:
participant = await database_sync_to_async(QuizGameParticipant.objects.get)(
participant_id=participant_id
)
participant.last_heartbeat = timezone.now()
await database_sync_to_async(participant.save)()
except QuizGameParticipant.DoesNotExist:
pass
@database_sync_to_async @database_sync_to_async
def verify_host(self, host_id): def verify_host(self, host_id):
try: try:
@@ -272,16 +369,20 @@ class GameConsumer(AsyncWebsocketConsumer):
try: try:
game = QuizGame.objects.get(join_code=self.join_code) game = QuizGame.objects.get(join_code=self.join_code)
# Get all active participants (heartbeat within last minute)
from django.utils import timezone
active_participants = QuizGameParticipant.objects.filter( active_participants = QuizGameParticipant.objects.filter(
quiz_game=game quiz_game=game,
last_heartbeat__gte=timezone.now() - timezone.timedelta(minutes=1)
).count() ).count()
if active_participants == 0: # If no active participants and game is finished for more than 5 minutes, delete it
if active_participants == 0 and game.current_state == 'finished':
if game.question_start_time and (timezone.now() - game.question_start_time).total_seconds() > 300:
game.delete() game.delete()
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:
pass pass
@database_sync_to_async @database_sync_to_async
def get_participants(self): def get_participants(self):
try: try:

View File

@@ -3,15 +3,53 @@ import json
import asyncio import asyncio
from ..models import QuizGame, QuizGameParticipant from ..models import QuizGame, QuizGameParticipant
from channels.db import database_sync_to_async from channels.db import database_sync_to_async
import redis
# Dictionary to track inactive check tasks and active connections per game # Dictionary to track inactive check tasks and active connections per game
game_check_tasks = {} game_check_tasks = {}
game_connections = {} game_connections = {}
class LobbyConsumer(AsyncWebsocketConsumer): class LobbyConsumer(AsyncWebsocketConsumer):
@classmethod
async def start_inactive_check(cls, join_code, channel_layer):
"""Start the inactive player check for a game if not already running."""
if join_code not in game_check_tasks or game_check_tasks[join_code].done():
async def check_inactive_players():
room_group_name = f'lobby_{join_code}'
while True:
try:
await asyncio.sleep(10) # Check every 10 seconds to match frontend heartbeat
# Get participants list and broadcast update
try:
from django.utils import timezone
from datetime import timedelta
game = await database_sync_to_async(QuizGame.objects.get)(join_code=join_code)
cutoff_time = timezone.now() - timedelta(seconds=20) # Consider inactive after 20 seconds (2 missed heartbeats)
# Get current active participants with full info
active_participants = await database_sync_to_async(lambda: list(
game.participants.filter(last_heartbeat__gte=cutoff_time)
.values('participant_id', 'display_name')
))()
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True) # Send update to all clients
await channel_layer.group_send(
room_group_name,
{
'type': 'participants_list_update',
'participants': active_participants
}
)
except QuizGame.DoesNotExist:
break # Stop checking if game no longer exists
except Exception as e:
print(f'Error checking inactive players: {e}')
await asyncio.sleep(5) # Wait before retry
except asyncio.CancelledError:
break
except Exception as e:
print(f'Error in inactive check loop: {e}')
await asyncio.sleep(5)
game_check_tasks[join_code] = asyncio.create_task(check_inactive_players())
async def connect(self): async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code'] self.join_code = self.scope['url_route']['kwargs']['join_code']
@@ -25,6 +63,9 @@ class LobbyConsumer(AsyncWebsocketConsumer):
game_connections[self.join_code] = set() game_connections[self.join_code] = set()
game_connections[self.join_code].add(self.channel_name) game_connections[self.join_code].add(self.channel_name)
# Start inactive check task if not already running
await self.start_inactive_check(self.join_code, self.channel_layer)
# Join room group # Join room group
await self.channel_layer.group_add( await self.channel_layer.group_add(
self.room_group_name, self.room_group_name,
@@ -66,6 +107,11 @@ class LobbyConsumer(AsyncWebsocketConsumer):
})) }))
return return
if message_type == 'heartbeat':
participant_id = data.get('participant_id')
if participant_id:
await self.update_participant_heartbeat(participant_id)
# Don't update participants list after heartbeat - let the background task handle it
elif message_type in ['leave_game', 'kick_player']: elif message_type in ['leave_game', 'kick_player']:
participant_id = data.get('participant_id') participant_id = data.get('participant_id')
if participant_id: if participant_id:
@@ -134,32 +180,36 @@ class LobbyConsumer(AsyncWebsocketConsumer):
} }
) )
@database_sync_to_async
def update_participant_heartbeat(self, participant_id):
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
from django.utils import timezone
participant.last_heartbeat = timezone.now()
participant.save()
return True
except QuizGameParticipant.DoesNotExist:
return False
async def get_participants_list(self): async def get_participants_list(self):
try: try:
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
game = await database_sync_to_async(QuizGame.objects.get)(join_code=self.join_code) game = await database_sync_to_async(QuizGame.objects.get)(join_code=self.join_code)
# Only return participants that have been seen in the last minute
cutoff_time = timezone.now() - timedelta(seconds=20) # Consider inactive after 20 seconds (2 missed heartbeats)
participants = await database_sync_to_async(lambda: list( participants = await database_sync_to_async(lambda: list(
game.participants game.participants.filter(last_heartbeat__gte=cutoff_time)
.values('participant_id', 'display_name') .values('participant_id', 'display_name')
))() ))()
# Zustand laden # Send join notification for new participants
try: try:
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True) prev_names = set(p['display_name'] for p in self.last_participants) if hasattr(self, 'last_participants') else set()
# Alte Teilnehmer aus Redis holen
data = r.get(f"last_participants_{self.room_group_name}")
prev_names = set(json.loads(data)) if data else set()
# Aktuelle Teilnehmer aus dem Argument extrahieren
current_names = set(p['display_name'] for p in participants) current_names = set(p['display_name'] for p in participants)
# Neue Teilnehmer bestimmen
new_participants = current_names - prev_names new_participants = current_names - prev_names
# Benachrichtigung für neue Teilnehmer versenden
for name in new_participants: for name in new_participants:
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.room_group_name, self.room_group_name,
@@ -168,12 +218,9 @@ class LobbyConsumer(AsyncWebsocketConsumer):
'player_name': name 'player_name': name
} }
) )
# Teilnehmerliste in Redis aktualisieren
r.set(f"last_participants_{self.room_group_name}", json.dumps(list(current_names)))
except Exception as e: except Exception as e:
print(f"Error in send_join_notifications: {e}") print(f'Error sending join notifications: {e}')
self.last_participants = participants self.last_participants = participants
return participants return participants
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-21 11:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0010_alter_quizgameparticipant_last_heartbeat'),
]
operations = [
migrations.AddField(
model_name='quizgame',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -19,7 +19,6 @@ class QuizGame(models.Model):
current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby') current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby')
current_question_index = models.IntegerField(default=0) current_question_index = models.IntegerField(default=0)
question_start_time = models.DateTimeField(null=True, blank=True) question_start_time = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.join_code: if not self.join_code:

View File

@@ -11,9 +11,6 @@ import json
# Create your views here. # Create your views here.
def lobby(request, join_code): def lobby(request, join_code):
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)
context = { context = {
@@ -21,7 +18,6 @@ def lobby(request, join_code):
'quiz_game': quiz_game, 'quiz_game': quiz_game,
} }
mode = request.GET.get('mode','default') mode = request.GET.get('mode','default')
request.session['mode'] = mode request.session['mode'] = mode
if "host_id" in request.COOKIES: if "host_id" in request.COOKIES:
@@ -32,9 +28,6 @@ def lobby(request, join_code):
elif mode=="learn": elif mode=="learn":
return redirect('play:create_participant', join_code=join_code) return redirect('play:create_participant', join_code=join_code)
if not "participant_id" in request.COOKIES: if not "participant_id" in request.COOKIES:
return redirect('play:create_participant', join_code=join_code) return redirect('play:create_participant', join_code=join_code)
@@ -54,14 +47,12 @@ def lobby(request, join_code):
def select_mode(request,join_code): def select_mode(request,join_code):
return render(request, 'play/select_mode.html', {'join_code': join_code}) return render(request, 'play/select_mode.html', {'join_code': join_code})
def create_participant(request, join_code): def create_participant(request, join_code):
mode = request.GET.get('mode','default') mode = request.GET.get('mode','default')
request.session['mode'] = mode request.session['mode'] = mode
quiz_game = get_object_or_404(QuizGame, join_code=join_code) quiz_game = get_object_or_404(QuizGame, join_code=join_code)
if mode!="learn": if mode!="learn":
if "participant_id" in request.COOKIES: if request.method == 'GET' and "participant_id" in request.COOKIES:
# Prüfe ob der Teilnehmer noch existiert # Prüfe ob der Teilnehmer noch existiert
participant_id = request.COOKIES['participant_id'] participant_id = request.COOKIES['participant_id']
try: try:
@@ -70,7 +61,7 @@ def create_participant(request, join_code):
participant.quiz_game = quiz_game participant.quiz_game = quiz_game
participant.score = 0 # Reset score when joining new game participant.score = 0 # Reset score when joining new game
participant.save() participant.save()
return redirect('play:lobby', join_code=join_code) return render(request, 'play/initialize_participant.html', {'participant': participant, 'join_code': quiz_game.join_code})
except QuizGameParticipant.DoesNotExist: except QuizGameParticipant.DoesNotExist:
# Cookie löschen wenn Teilnehmer nicht mehr existiert # Cookie löschen wenn Teilnehmer nicht mehr existiert
response = HttpResponseRedirect(request.path) response = HttpResponseRedirect(request.path)

View File

@@ -51,24 +51,23 @@
<div id="filterPanel" class="container mx-auto px-4"> <div id="filterPanel" class="container mx-auto px-4">
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200"> <div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
<form action="{% url 'library:overview_quiz' %}" method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6"> <form method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6">
<input type="hidden" name="filter" value="1">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">minimale Anzahl an Fragen</label> <label class="block text-sm font-medium text-gray-700">minimale Anzahl an Fragen</label>
<input type="number" name="min_amout_questions" class="border-2 border-gray-300 rounded-lg p-2 w-full" min="1" id="id_min_amout_questions"> {{ form.min_amout_questions }}
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">maximale Anzahl an Fragen</label> <label class="block text-sm font-medium text-gray-700">maximale Anzahl an Fragen</label>
<input type="number" name="max_amout_questions" class="border-2 border-gray-300 rounded-lg p-2 w-full" min="1" id="id_max_amout_questions"> {{ form.max_amout_questions }}
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">veröffentlicht von User</label> <label class="block text-sm font-medium text-gray-700">veröffentlicht von User</label>
<input type="text" name="user" class="border-2 border-gray-300 rounded-lg p-2 w-full" id="id_user"> {{ form.user }}
</div> </div>
<div class="md:col-span-3 flex justify-end"> <div class="md:col-span-3 flex justify-end">
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200"> <button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg> </svg>
Filtern Filtern
</button> </button>
@@ -634,26 +633,11 @@
{% endif %} {% endif %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('filter') === '1') {
document.getElementById('filterPanel').classList.add('show');
}
document.getElementById('filterButton').addEventListener('click', function() { document.getElementById('filterButton').addEventListener('click', function() {
document.getElementById('filterPanel').classList.toggle('show'); document.getElementById('filterPanel').classList.toggle('show');
// URL beim Öffnen aktualisieren
const params = new URLSearchParams(window.location.search);
if (document.getElementById('filterPanel').classList.contains('show')) {
params.set('filter', '1');
} else {
params.delete('filter');
}
history.replaceState(null, '', `${location.pathname}?${params}`);
}); });
}); });
if (window.location.pathname === "/library/") { if (window.location.pathname === "/library/") {
window.addEventListener("beforeunload", function () { window.addEventListener("beforeunload", function () {
localStorage.setItem("meine_seite_scroll", window.scrollY); localStorage.setItem("meine_seite_scroll", window.scrollY);

View File

@@ -24,7 +24,8 @@
required required
maxlength="20" maxlength="20"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Dein Spielername"> placeholder="Dein Spielername"
value="{{ participant.display_name }}">
<p class="text-xs text-gray-500 mt-2">Maximal 20 Zeichen</p> <p class="text-xs text-gray-500 mt-2">Maximal 20 Zeichen</p>
</div> </div>

View File

@@ -22,6 +22,7 @@
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span> <span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
<span>Angemeldet als <b>{{ participant.display_name }}</b></span> <span>Angemeldet als <b>{{ participant.display_name }}</b></span>
</div> </div>
<a href="{% url 'play:create_participant' quiz_game.join_code %}">Auftreten ändern...</a>
</div> </div>
{% endif %} {% endif %}
@@ -33,7 +34,7 @@
</svg> </svg>
Teilnehmer Teilnehmer
</h3> </h3>
<ul id="participants-list" class="space-y-2 max-h-60"> <ul id="participants-list" class="space-y-2 max-h-60 overflow-y-auto">
<li class="text-gray-500 p-3 bg-gray-50 rounded-lg text-center">Warte auf Teilnehmer...</li> <li class="text-gray-500 p-3 bg-gray-50 rounded-lg text-center">Warte auf Teilnehmer...</li>
</ul> </ul>
</div> </div>