Compare commits
2 Commits
91-bilder-
...
44-zurueck
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dc30f9cbc | ||
|
|
3df748dc96 |
20
README.md
20
README.md
@@ -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
|
||||
```
|
||||
|
||||
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:
|
||||
```sh
|
||||
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
|
||||
- `-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!
|
||||
|
||||
## Superuser erstellen
|
||||
|
||||
@@ -79,14 +79,10 @@ WSGI_APPLICATION = 'core.wsgi.application'
|
||||
ASGI_APPLICATION = 'core.asgi.application'
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [("127.0.0.1", 6379)],
|
||||
},
|
||||
},
|
||||
'default': {
|
||||
'BACKEND': 'channels.layers.InMemoryChannelLayer'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Database
|
||||
|
||||
@@ -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.')
|
||||
@@ -266,8 +266,6 @@ def copy_quiz(request, pk):
|
||||
new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User
|
||||
new_quiz.name += " - Kopie" # Optional: Name anpassen
|
||||
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.save() # Speichern als neues Objekt
|
||||
# Optional: Beschreibung anpassen
|
||||
|
||||
@@ -9,7 +9,75 @@ from datetime import timedelta
|
||||
|
||||
from play.models import QuizGame, QuizGameParticipant, QuizAnswer
|
||||
|
||||
# Dictionary to track inactive check tasks per game
|
||||
game_check_tasks = {}
|
||||
|
||||
|
||||
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):
|
||||
self.join_code = self.scope['url_route']['kwargs']['join_code']
|
||||
@@ -23,6 +91,9 @@ class GameConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
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):
|
||||
# Leave game group
|
||||
await self.channel_layer.group_discard(
|
||||
@@ -75,6 +146,21 @@ class GameConsumer(AsyncWebsocketConsumer):
|
||||
pass
|
||||
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':
|
||||
participant_id = text_data_json['participant_id']
|
||||
rating = text_data_json['rating']
|
||||
@@ -164,6 +250,17 @@ class GameConsumer(AsyncWebsocketConsumer):
|
||||
'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
|
||||
def verify_host(self, host_id):
|
||||
try:
|
||||
@@ -272,16 +369,20 @@ class GameConsumer(AsyncWebsocketConsumer):
|
||||
try:
|
||||
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(
|
||||
quiz_game=game
|
||||
quiz_game=game,
|
||||
last_heartbeat__gte=timezone.now() - timezone.timedelta(minutes=1)
|
||||
).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()
|
||||
except QuizGame.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
@database_sync_to_async
|
||||
def get_participants(self):
|
||||
try:
|
||||
|
||||
@@ -3,15 +3,53 @@ import json
|
||||
import asyncio
|
||||
from ..models import QuizGame, QuizGameParticipant
|
||||
from channels.db import database_sync_to_async
|
||||
import redis
|
||||
|
||||
# Dictionary to track inactive check tasks and active connections per game
|
||||
game_check_tasks = {}
|
||||
game_connections = {}
|
||||
|
||||
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):
|
||||
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].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
|
||||
await self.channel_layer.group_add(
|
||||
self.room_group_name,
|
||||
@@ -66,6 +107,11 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
||||
}))
|
||||
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']:
|
||||
participant_id = data.get('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):
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
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(
|
||||
game.participants
|
||||
game.participants.filter(last_heartbeat__gte=cutoff_time)
|
||||
.values('participant_id', 'display_name')
|
||||
))()
|
||||
|
||||
|
||||
# Zustand laden
|
||||
# Send join notification for new participants
|
||||
try:
|
||||
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
|
||||
|
||||
# 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
|
||||
prev_names = set(p['display_name'] for p in self.last_participants) if hasattr(self, 'last_participants') else set()
|
||||
current_names = set(p['display_name'] for p in participants)
|
||||
|
||||
# Neue Teilnehmer bestimmen
|
||||
new_participants = current_names - prev_names
|
||||
|
||||
# Benachrichtigung für neue Teilnehmer versenden
|
||||
for name in new_participants:
|
||||
await self.channel_layer.group_send(
|
||||
self.room_group_name,
|
||||
@@ -168,12 +218,9 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
||||
'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:
|
||||
print(f"Error in send_join_notifications: {e}")
|
||||
print(f'Error sending join notifications: {e}')
|
||||
|
||||
self.last_participants = participants
|
||||
return participants
|
||||
except QuizGame.DoesNotExist:
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -19,7 +19,6 @@ class QuizGame(models.Model):
|
||||
current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby')
|
||||
current_question_index = models.IntegerField(default=0)
|
||||
question_start_time = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.join_code:
|
||||
|
||||
@@ -11,9 +11,6 @@ import json
|
||||
|
||||
# Create your views here.
|
||||
def lobby(request, 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)
|
||||
context = {
|
||||
@@ -21,7 +18,6 @@ def lobby(request, join_code):
|
||||
'quiz_game': quiz_game,
|
||||
}
|
||||
|
||||
|
||||
mode = request.GET.get('mode','default')
|
||||
request.session['mode'] = mode
|
||||
if "host_id" in request.COOKIES:
|
||||
@@ -32,9 +28,6 @@ def lobby(request, join_code):
|
||||
elif mode=="learn":
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
|
||||
|
||||
|
||||
if not "participant_id" in request.COOKIES:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
@@ -54,14 +47,12 @@ def lobby(request, join_code):
|
||||
def select_mode(request,join_code):
|
||||
return render(request, 'play/select_mode.html', {'join_code': join_code})
|
||||
|
||||
|
||||
|
||||
def create_participant(request, join_code):
|
||||
mode = request.GET.get('mode','default')
|
||||
request.session['mode'] = mode
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
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
|
||||
participant_id = request.COOKIES['participant_id']
|
||||
try:
|
||||
@@ -70,7 +61,7 @@ def create_participant(request, join_code):
|
||||
participant.quiz_game = quiz_game
|
||||
participant.score = 0 # Reset score when joining new game
|
||||
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:
|
||||
# Cookie löschen wenn Teilnehmer nicht mehr existiert
|
||||
response = HttpResponseRedirect(request.path)
|
||||
|
||||
@@ -51,24 +51,23 @@
|
||||
|
||||
<div id="filterPanel" class="container mx-auto px-4">
|
||||
<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">
|
||||
<input type="hidden" name="filter" value="1">
|
||||
<form method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="space-y-2">
|
||||
<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 class="space-y-2">
|
||||
<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 class="space-y-2">
|
||||
<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 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">
|
||||
<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>
|
||||
Filtern
|
||||
</button>
|
||||
@@ -634,26 +633,11 @@
|
||||
{% endif %}
|
||||
<script>
|
||||
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('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/") {
|
||||
window.addEventListener("beforeunload", function () {
|
||||
localStorage.setItem("meine_seite_scroll", window.scrollY);
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
required
|
||||
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"
|
||||
placeholder="Dein Spielername">
|
||||
placeholder="Dein Spielername"
|
||||
value="{{ participant.display_name }}">
|
||||
<p class="text-xs text-gray-500 mt-2">Maximal 20 Zeichen</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
<span>Angemeldet als <b>{{ participant.display_name }}</b></span>
|
||||
</div>
|
||||
<a href="{% url 'play:create_participant' quiz_game.join_code %}">Auftreten ändern...</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
</svg>
|
||||
Teilnehmer
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user