UI Refactoring and Fix of image issue when adding an image to a quiz
This commit is contained in:
@@ -98,6 +98,9 @@ STORAGES = {
|
|||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
},
|
},
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ from django.contrib.auth.models import User
|
|||||||
# Übersicht aller Quizze
|
# Übersicht aller Quizze
|
||||||
|
|
||||||
def overview_quiz(request):
|
def overview_quiz(request):
|
||||||
|
|
||||||
# Filter
|
# Filter
|
||||||
form = QuizFilterForm(request.GET)
|
form = QuizFilterForm(request.GET)
|
||||||
|
|
||||||
|
# Initialize querysets
|
||||||
try:
|
if request.user.is_authenticated:
|
||||||
|
|
||||||
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions'))
|
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions'))
|
||||||
except:
|
filter_other_quizzes = QivipQuiz.objects.exclude(user_id=request.user)
|
||||||
|
else:
|
||||||
filter_my_quizzes = QivipQuiz.objects.none()
|
filter_my_quizzes = QivipQuiz.objects.none()
|
||||||
|
filter_other_quizzes = QivipQuiz.objects.all()
|
||||||
|
|
||||||
filter_other_quizzes = QivipQuiz.objects.annotate(max_amout_questions=Count('questions'))
|
# Apply common filters to other quizzes
|
||||||
|
filter_other_quizzes = filter_other_quizzes.annotate(max_amout_questions=Count('questions'))
|
||||||
|
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=1, status='öffentlich')
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
search = form.cleaned_data.get('search')
|
search = form.cleaned_data.get('search')
|
||||||
@@ -41,10 +41,7 @@ def overview_quiz(request):
|
|||||||
|
|
||||||
if min_amout_questions:
|
if min_amout_questions:
|
||||||
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=min_amout_questions)
|
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=min_amout_questions)
|
||||||
try:
|
filter_my_quizzes = filter_my_quizzes.filter(max_amout_questions__gte=min_amout_questions) if filter_my_quizzes else QivipQuiz.objects.none()
|
||||||
filter_my_quizzes =filter_my_quizzes.filter(max_amout_questions__gte=min_amout_questions)
|
|
||||||
except:
|
|
||||||
filter_my_quizzes = QivipQuiz.objects.none()
|
|
||||||
|
|
||||||
if max_amout_questions:
|
if max_amout_questions:
|
||||||
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__lte=max_amout_questions)
|
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__lte=max_amout_questions)
|
||||||
@@ -69,16 +66,27 @@ def overview_quiz(request):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=1).filter(status='öffentlich')
|
# Pagination for my quizzes
|
||||||
|
my_quizzes_paginator = Paginator(filter_my_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page
|
||||||
|
try:
|
||||||
|
my_quizzes = my_quizzes_paginator.page(request.GET.get('my_page', 1))
|
||||||
|
except:
|
||||||
|
my_quizzes = my_quizzes_paginator.page(1)
|
||||||
|
|
||||||
|
# Pagination for other quizzes
|
||||||
|
other_quizzes_paginator = Paginator(filter_other_quizzes.order_by('-creation_date'), 8) # 8 quizzes per page
|
||||||
|
try:
|
||||||
|
other_quizzes = other_quizzes_paginator.page(request.GET.get('other_page', 1))
|
||||||
|
except:
|
||||||
|
other_quizzes = other_quizzes_paginator.page(1)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'show_search': True,
|
'show_search': True,
|
||||||
'filter_my_quizzes': filter_my_quizzes.order_by('-creation_date'),
|
'my_quizzes': my_quizzes,
|
||||||
'filter_other_quizzes': filter_other_quizzes.order_by('-creation_date'),
|
'other_quizzes': other_quizzes,
|
||||||
'form': form,
|
'form': form,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return render(request, 'library/overview_quiz.html', context)
|
return render(request, 'library/overview_quiz.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ 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
|
||||||
|
|
||||||
# Dictionary to track inactive check tasks per game
|
# Dictionary to track inactive check tasks and active connections per game
|
||||||
game_check_tasks = {}
|
game_check_tasks = {}
|
||||||
|
game_connections = {}
|
||||||
|
|
||||||
class LobbyConsumer(AsyncWebsocketConsumer):
|
class LobbyConsumer(AsyncWebsocketConsumer):
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -16,39 +17,20 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
room_group_name = f'lobby_{join_code}'
|
room_group_name = f'lobby_{join_code}'
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(30) # Check every 30 seconds
|
await asyncio.sleep(10) # Check every 10 seconds to match frontend heartbeat
|
||||||
# Get participants list and broadcast update
|
# Get participants list and broadcast update
|
||||||
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=join_code)
|
game = await database_sync_to_async(QuizGame.objects.get)(join_code=join_code)
|
||||||
cutoff_time = timezone.now() - timedelta(minutes=1)
|
cutoff_time = timezone.now() - timedelta(seconds=20) # Consider inactive after 20 seconds (2 missed heartbeats)
|
||||||
# Get previous active participants
|
# Get current active participants with full info
|
||||||
prev_active = await database_sync_to_async(lambda: set(
|
|
||||||
game.participants.values_list('display_name', flat=True)
|
|
||||||
))()
|
|
||||||
|
|
||||||
# Get current active participants
|
|
||||||
active_participants = await database_sync_to_async(lambda: list(
|
active_participants = await database_sync_to_async(lambda: list(
|
||||||
game.participants.filter(last_heartbeat__gte=cutoff_time)
|
game.participants.filter(last_heartbeat__gte=cutoff_time)
|
||||||
.values_list('display_name', flat=True)
|
.values('participant_id', 'display_name')
|
||||||
))()
|
))()
|
||||||
|
|
||||||
# Find removed participants
|
# Send update to all clients
|
||||||
removed = set(prev_active) - set(active_participants)
|
|
||||||
|
|
||||||
# Send notifications for removed participants
|
|
||||||
for name in removed:
|
|
||||||
await channel_layer.group_send(
|
|
||||||
room_group_name,
|
|
||||||
{
|
|
||||||
'type': 'player_left',
|
|
||||||
'player_name': name,
|
|
||||||
'was_kicked': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast update to all clients
|
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
room_group_name,
|
room_group_name,
|
||||||
{
|
{
|
||||||
@@ -76,6 +58,14 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
# Get participant ID from session
|
# Get participant ID from session
|
||||||
self.participant_id = self.scope['session'].get('participant_id')
|
self.participant_id = self.scope['session'].get('participant_id')
|
||||||
|
|
||||||
|
# Track connection
|
||||||
|
if self.join_code not in game_connections:
|
||||||
|
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
|
# Join room group
|
||||||
await self.channel_layer.group_add(
|
await self.channel_layer.group_add(
|
||||||
self.room_group_name,
|
self.room_group_name,
|
||||||
@@ -87,9 +77,6 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
# Send initial participants list
|
# Send initial participants list
|
||||||
await self.update_participants()
|
await self.update_participants()
|
||||||
|
|
||||||
# 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 room group
|
# Leave room group
|
||||||
await self.channel_layer.group_discard(
|
await self.channel_layer.group_discard(
|
||||||
@@ -97,6 +84,17 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
self.channel_name
|
self.channel_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Remove from connection tracking
|
||||||
|
if self.join_code in game_connections:
|
||||||
|
game_connections[self.join_code].discard(self.channel_name)
|
||||||
|
if not game_connections[self.join_code]:
|
||||||
|
# Last connection closed
|
||||||
|
del game_connections[self.join_code]
|
||||||
|
if self.join_code in game_check_tasks:
|
||||||
|
# Cancel the inactive check task
|
||||||
|
game_check_tasks[self.join_code].cancel()
|
||||||
|
del game_check_tasks[self.join_code]
|
||||||
|
|
||||||
async def receive(self, text_data):
|
async def receive(self, text_data):
|
||||||
data = json.loads(text_data)
|
data = json.loads(text_data)
|
||||||
message_type = data.get('type')
|
message_type = data.get('type')
|
||||||
@@ -113,8 +111,7 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
participant_id = data.get('participant_id')
|
participant_id = data.get('participant_id')
|
||||||
if participant_id:
|
if participant_id:
|
||||||
await self.update_participant_heartbeat(participant_id)
|
await self.update_participant_heartbeat(participant_id)
|
||||||
# Update participants list after heartbeat
|
# Don't update participants list after heartbeat - let the background task handle it
|
||||||
await self.update_participants()
|
|
||||||
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:
|
||||||
@@ -200,7 +197,7 @@ class LobbyConsumer(AsyncWebsocketConsumer):
|
|||||||
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
|
# Only return participants that have been seen in the last minute
|
||||||
cutoff_time = timezone.now() - timedelta(minutes=1)
|
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.filter(last_heartbeat__gte=cutoff_time)
|
game.participants.filter(last_heartbeat__gte=cutoff_time)
|
||||||
.values('participant_id', 'display_name')
|
.values('participant_id', 'display_name')
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-04-06 17:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('play', '0009_quizanswer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='quizgameparticipant',
|
||||||
|
name='last_heartbeat',
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -43,7 +43,7 @@ class QuizGameParticipant(models.Model):
|
|||||||
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants")
|
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants")
|
||||||
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)
|
last_heartbeat = models.DateTimeField(auto_now_add=True)
|
||||||
last_answer = models.IntegerField(null=True, blank=True)
|
last_answer = models.IntegerField(null=True, blank=True)
|
||||||
last_answer_correct = models.BooleanField(null=True, blank=True)
|
last_answer_correct = models.BooleanField(null=True, blank=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
{% block extra_css %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="stylesheet" href="{% static 'swiper/swiper-bundle.min.css' %}">
|
<link rel="stylesheet" href="{% static 'swiper/swiper-bundle.min.css' %}">
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.swiper {
|
.swiper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -24,213 +18,375 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#filterPanel {
|
||||||
</style>
|
transition: all 0.3s ease-in-out;
|
||||||
</head>
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
<div class="flex justify-end mr-2">
|
opacity: 0;
|
||||||
<a href="{% url 'library:new_quiz' %}" class="mt-2 p-2 shadow-md border-blue-600 border-2 rounded-md text-black ">Neues Quiz erstellen</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end mr-2">
|
|
||||||
<a href="{% url 'library:overview_quiz' %}" class="mt-2 p-2 shadow-md border-blue-600 border-2 rounded-md text-black ">Suche und Filter zurücksetzen</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-screen flex justify-center items-center">
|
|
||||||
<div class="w-full m-4 border-blue-600 border-2 rounded-xl shadow-md p-6 lg:w-1/4">
|
|
||||||
<form method="get" class="flex flex-col space-y-4">
|
|
||||||
<h1 class="text-xl font-bold">Filter</h1>
|
|
||||||
Minimale Anzahl an Fragen {{ form.min_amout_questions }}
|
|
||||||
Maximale Anzahl an Fragen {{ form.max_amout_questions }}
|
|
||||||
von User {{ form.user }}
|
|
||||||
<!-- Rendert die Form-Felder -->
|
|
||||||
<button type="submit" class="qp-a-button-small border-2 border-blue-600 text-black">Filtern</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Swiper Container für eigene Quiz -->
|
|
||||||
{% if filter_my_quizzes %}
|
|
||||||
<div class="text-center m-8 bg-blue-600 grid mx-8 h-8 place-items-center rounded-xl shadow-md ">
|
|
||||||
<h1 id="my-quizzes" class="flex px-4 font-bold text-white">Eigene Quiz</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="swiper mySwiper">
|
|
||||||
<div class="swiper-wrapper">
|
|
||||||
{% 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-xl p-4 shadow-md border-blue-600 border-2 flex flex-col justify-between"
|
|
||||||
{% 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>
|
|
||||||
|
|
||||||
<p class="text-white font-bold drop-shadow-lg custom-outline">
|
|
||||||
<span class="break-words line-clamp-2 ">{{ quiz.description }}</span><br>
|
|
||||||
Schwierigkeit: <span class="font-bold">{{ quiz.difficulty }}</span><br>
|
|
||||||
Anzahl der Fragen: <span class="font-bold">{{ quiz.question.count }}</span><br>
|
|
||||||
Status: <span class="font-bold">{{ quiz.status }}</span><br>
|
|
||||||
Bewertung: <span class="font-bold">
|
|
||||||
{% for i in '12345'|make_list %}
|
|
||||||
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
|
|
||||||
<span class="text-yellow-400">★</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400">★</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
({{ quiz.rating_count }})
|
|
||||||
</span>
|
|
||||||
|
|
||||||
|
|
||||||
<p class=" font-bold break-words truncate text-white drop-shadow-lg custom-outline">
|
|
||||||
{% if quiz.authors.all %}
|
|
||||||
Autor(en):
|
|
||||||
{% for author in quiz.authors.all %}
|
|
||||||
|
|
||||||
{{ author.username }}{% if not forloop.last %}, {% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Buttons bleiben am unteren Rand -->
|
|
||||||
<div class="flex justify-between items-center gap-2 ">
|
|
||||||
<a href="{% url 'play:create_game' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold 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">
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'library:delete_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">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if filter_my_quizzes|length > 1 %}
|
|
||||||
<div class="swiper-button-next"></div>
|
|
||||||
<div class="swiper-button-prev"></div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Swiper Container für Quiz von anderen Nutzern -->
|
|
||||||
{% if filter_other_quizzes %}
|
|
||||||
<div class="text-center m-8 bg-blue-600 grid mx-8 h-8 place-items-center rounded-xl shadow-md">
|
|
||||||
<h1 id="other-quizzes" class="flex px-4 font-bold text-white">Quiz von anderen Nutzern</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="swiper mySwiper">
|
|
||||||
<div class="swiper-wrapper">
|
|
||||||
|
|
||||||
|
|
||||||
{% for quiz in filter_other_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 %}>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<p class="text-white font-bold text-white drop-shadow-lg custom-outline">
|
|
||||||
<span class="break-words line-clamp-2 ">{{ quiz.description }}</span><br>
|
|
||||||
Schwierigkeit: <span class="font-bold">{{ quiz.difficulty }}</span><br>
|
|
||||||
Anzahl der Fragen: <span class="font-bold ">{{ quiz.question.count }}</span><br>
|
|
||||||
Erstellt am: <span class="font-bold ">{{ quiz.creation_date }}</span>
|
|
||||||
</p>
|
|
||||||
{% if quiz.original_creator %}
|
|
||||||
<p class=" font-bold break-words truncate text-white drop-shadow-lg custom-outline">Ursprünglich erstellt von: {{ quiz.original_creator }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Buttons bleiben am unteren Rand -->
|
|
||||||
<div class="flex justify-between items-center gap-2 ">
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</a>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<a href="{% url 'library:overview_quiz' %}?user={{ quiz.user_id }}" class="qp-a-button-small border-2 border-blue-600 text-white font-bold drop-shadow-lg custom-outline">
|
|
||||||
|
|
||||||
<button type="submit" class=" text-sm py-1 text-white font-bold drop-shadow-lg custom-outline"> veröffentlicht von {{ quiz.user_id }}</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% if filter_other_quizzes|length > 1 %}
|
|
||||||
<div class="swiper-button-next"></div>
|
|
||||||
<div class="swiper-button-prev"></div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if not filter_other_quizzes and not filter_my_quizzes %}
|
|
||||||
<div class="grid text-center font-bold items-center">Es wurde kein passendes Quiz gefunden!</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Swiper.js -->
|
|
||||||
<script src="{% static 'swiper/swiper-bundle.min.js' %}"></script>
|
|
||||||
<script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var swiper = new Swiper('.swiper', {
|
|
||||||
slidesPerView: getSlidesPerView(),
|
|
||||||
direction: getDirection(),
|
|
||||||
navigation: {
|
|
||||||
nextEl: '.swiper-button-next',
|
|
||||||
prevEl: '.swiper-button-prev',
|
|
||||||
},
|
|
||||||
on: {
|
|
||||||
resize: function () {
|
|
||||||
swiper.changeDirection(getDirection());
|
|
||||||
swiper.params.slidesPerView = getSlidesPerView();
|
|
||||||
swiper.update();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function getDirection() {
|
|
||||||
return window.innerWidth <= 0? 'vertical' : 'horizontal';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSlidesPerView() {
|
#filterPanel.show {
|
||||||
if (window.innerWidth <= 840) {
|
max-height: 500px;
|
||||||
return 1; // Smartphones
|
opacity: 1;
|
||||||
} else if (window.innerWidth <= 1024) {
|
|
||||||
return 2; // Tablets
|
|
||||||
} else {
|
|
||||||
return 3; // Desktops
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.custom-outline {
|
.custom-outline {
|
||||||
-webkit-text-stroke: 0.2px rgb(0, 0, 0); /* Schwarze Umrandung */
|
-webkit-text-stroke: 0.2px rgb(0, 0, 0);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<button onclick="toggleFilter()" class="p-2 hover:bg-gray-100 rounded-lg transition-colors duration-200 flex items-center gap-2 text-gray-700">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" 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" />
|
||||||
|
</svg>
|
||||||
|
<span>Filter</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="{% url 'library:overview_quiz' %}" class="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
Zurücksetzen
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'library:new_quiz' %}" 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="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Neues Quiz
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="filterPanel" class="container mx-auto px-4 mb-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||||
|
<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 Fragen</label>
|
||||||
|
{{ form.min_amout_questions }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Maximale Fragen</label>
|
||||||
|
{{ form.max_amout_questions }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Von User</label>
|
||||||
|
{{ 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" />
|
||||||
|
</svg>
|
||||||
|
Filtern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleFilter() {
|
||||||
|
const panel = document.getElementById('filterPanel');
|
||||||
|
panel.classList.toggle('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide filter panel on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const panel = document.getElementById('filterPanel');
|
||||||
|
panel.classList.remove('show');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 id="my-quizzes" class="text-2xl font-bold text-gray-900">Eigene Quiz</h2>
|
||||||
|
<div class="h-0.5 flex-grow bg-gradient-to-r from-blue-600 to-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 auto-rows-fr">
|
||||||
|
{% for quiz in my_quizzes %}
|
||||||
|
<article class="relative min-h-[20rem] rounded-xl shadow-lg overflow-hidden bg-white flex flex-col">
|
||||||
|
<!-- Image Section (Top) -->
|
||||||
|
<div class="h-48 w-full relative flex-shrink-0">
|
||||||
|
{% if quiz.image %}
|
||||||
|
<div class="absolute inset-0 bg-cover bg-center" style="background-image: url({{ quiz.image.url }});"></div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section (Bottom) -->
|
||||||
|
<div class="flex-grow bg-white p-4 flex flex-col gap-3">
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 break-words mb-2">
|
||||||
|
<a href="{% url 'library:detail_quiz' quiz.id %}" class="hover:text-blue-600 transition-colors">{{ quiz.name }}</a>
|
||||||
|
</h2>
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-sm text-gray-700 break-words mb-3">{{ quiz.description }}</p>
|
||||||
|
|
||||||
|
<!-- Quiz Info Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||||
|
<!-- Difficulty -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-800">{{ quiz.difficulty }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Questions Count -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-800">{{ quiz.questions.count }} Fragen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-800">{{ quiz.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{% for i in '12345'|make_list %}
|
||||||
|
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
|
||||||
|
<span class="text-yellow-500 text-base">★</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-300 text-base">★</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authors -->
|
||||||
|
{% if quiz.authors.all %}
|
||||||
|
<div class="flex items-center gap-2 text-sm mt-2">
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-600">
|
||||||
|
{% for author in quiz.authors.all %}
|
||||||
|
{{ author.username }}{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">
|
||||||
|
<a href="{% url 'play:create_game' quiz.id %}"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Spielen
|
||||||
|
</a>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<a href="{% url 'library:detail_quiz' quiz.id %}"
|
||||||
|
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% if quiz.user == request.user %}
|
||||||
|
<a href="{% url 'library:edit_quiz' quiz.id %}"
|
||||||
|
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'library:delete_quiz' quiz.id %}"
|
||||||
|
class="p-1.5 bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors duration-200">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination für eigene Quiz -->
|
||||||
|
{% if my_quizzes.paginator.num_pages > 1 %}
|
||||||
|
<div class="flex justify-center space-x-2 mt-6 mb-8">
|
||||||
|
{% if my_quizzes.has_previous %}
|
||||||
|
<a href="?my_page={{ my_quizzes.previous_page_number }}{% if request.GET.other_page %}&other_page={{ request.GET.other_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||||
|
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="px-3 py-1 text-gray-600">Seite {{ my_quizzes.number }} von {{ my_quizzes.paginator.num_pages }}</span>
|
||||||
|
|
||||||
|
{% if my_quizzes.has_next %}
|
||||||
|
<a href="?my_page={{ my_quizzes.next_page_number }}{% if request.GET.other_page %}&other_page={{ request.GET.other_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||||
|
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quiz von anderen Nutzern -->
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 id="other-quizzes" class="text-2xl font-bold text-gray-900">Quiz von anderen Nutzern</h2>
|
||||||
|
<div class="h-0.5 flex-grow bg-gradient-to-r from-blue-600 to-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if other_quizzes %}
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 auto-rows-fr">
|
||||||
|
{% for quiz in other_quizzes %}
|
||||||
|
<article class="relative min-h-[20rem] rounded-xl shadow-lg overflow-hidden bg-white flex flex-col">
|
||||||
|
<!-- Image Section (Top) -->
|
||||||
|
<div class="h-48 w-full relative flex-shrink-0">
|
||||||
|
{% if quiz.image %}
|
||||||
|
<div class="absolute inset-0 bg-cover bg-center" style="background-image: url({{ quiz.image.url }});"></div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Section (Bottom) -->
|
||||||
|
<div class="flex-grow bg-white p-4 flex flex-col gap-3">
|
||||||
|
<!-- Title -->
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 break-words mb-2">
|
||||||
|
<a href="{% url 'library:detail_quiz' quiz.id %}" class="hover:text-blue-600 transition-colors">{{ quiz.name }}</a>
|
||||||
|
</h2>
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-sm text-gray-700 break-words mb-3">{{ quiz.description }}</p>
|
||||||
|
|
||||||
|
<!-- Quiz Info Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||||
|
<!-- Difficulty -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-800">{{ quiz.difficulty }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Questions Count -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-800">{{ quiz.questions.count }} Fragen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-800">{{ quiz.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{% for i in '12345'|make_list %}
|
||||||
|
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
|
||||||
|
<span class="text-yellow-500 text-base">★</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-300 text-base">★</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authors -->
|
||||||
|
{% if quiz.authors.all %}
|
||||||
|
<div class="flex items-center gap-2 text-sm mt-2">
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-600">
|
||||||
|
{% for author in quiz.authors.all %}
|
||||||
|
{{ author.username }}{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">
|
||||||
|
<a href="{% url 'play:create_game' quiz.id %}"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Spielen
|
||||||
|
</a>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<a href="{% url 'library:detail_quiz' quiz.id %}"
|
||||||
|
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination für andere Quiz -->
|
||||||
|
{% if other_quizzes.paginator.num_pages > 1 %}
|
||||||
|
<div class="flex justify-center space-x-2 mt-6 mb-8">
|
||||||
|
{% if other_quizzes.has_previous %}
|
||||||
|
<a href="?other_page={{ other_quizzes.previous_page_number }}{% if request.GET.my_page %}&my_page={{ request.GET.my_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||||
|
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="px-3 py-1 text-gray-600">Seite {{ other_quizzes.number }} von {{ other_quizzes.paginator.num_pages }}</span>
|
||||||
|
|
||||||
|
{% if other_quizzes.has_next %}
|
||||||
|
<a href="?other_page={{ other_quizzes.next_page_number }}{% if request.GET.my_page %}&my_page={{ request.GET.my_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||||
|
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-4">
|
||||||
|
<p class="text-gray-600 text-center">Keine öffentlichen Quiz gefunden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
|
|||||||
@@ -70,11 +70,10 @@
|
|||||||
const maxReconnectAttempts = 5;
|
const maxReconnectAttempts = 5;
|
||||||
const pingDelay = 30000; // 30 seconds
|
const pingDelay = 30000; // 30 seconds
|
||||||
const heartbeatDelay = 10000; // 10 seconds
|
const heartbeatDelay = 10000; // 10 seconds
|
||||||
|
let heartbeatInterval = null;
|
||||||
|
|
||||||
function startPingInterval() {
|
function startPingInterval() {
|
||||||
if (pingInterval) {
|
stopPingInterval();
|
||||||
clearInterval(pingInterval);
|
|
||||||
}
|
|
||||||
pingInterval = setInterval(() => {
|
pingInterval = setInterval(() => {
|
||||||
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
|
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
|
||||||
lobbySocket.send(JSON.stringify({ type: 'ping' }));
|
lobbySocket.send(JSON.stringify({ type: 'ping' }));
|
||||||
@@ -89,6 +88,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startHeartbeat() {
|
||||||
|
stopHeartbeat();
|
||||||
|
heartbeatInterval = setInterval(() => {
|
||||||
|
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
|
||||||
|
lobbySocket.send(JSON.stringify({
|
||||||
|
type: 'heartbeat',
|
||||||
|
participant_id: '{{ participant.participant_id }}'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, heartbeatDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopHeartbeat() {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
if (lobbySocket && (lobbySocket.readyState === WebSocket.CONNECTING || lobbySocket.readyState === WebSocket.OPEN)) {
|
if (lobbySocket && (lobbySocket.readyState === WebSocket.CONNECTING || lobbySocket.readyState === WebSocket.OPEN)) {
|
||||||
return lobbySocket; // Already connected or connecting
|
return lobbySocket; // Already connected or connecting
|
||||||
@@ -109,6 +127,9 @@
|
|||||||
console.log('Lobby socket connected');
|
console.log('Lobby socket connected');
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
startPingInterval();
|
startPingInterval();
|
||||||
|
{% if participant %}
|
||||||
|
startHeartbeat();
|
||||||
|
{% endif %}
|
||||||
// Request initial participants list
|
// Request initial participants list
|
||||||
lobbySocket.send(JSON.stringify({
|
lobbySocket.send(JSON.stringify({
|
||||||
type: 'update_participants'
|
type: 'update_participants'
|
||||||
@@ -118,10 +139,12 @@
|
|||||||
lobbySocket.onclose = function(e) {
|
lobbySocket.onclose = function(e) {
|
||||||
wsUtil.handleClose(e);
|
wsUtil.handleClose(e);
|
||||||
stopPingInterval();
|
stopPingInterval();
|
||||||
|
stopHeartbeat();
|
||||||
lobbySocket = null;
|
lobbySocket = null;
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
if (reconnectAttempts < maxReconnectAttempts) {
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
setTimeout(connectWebSocket, 1000 * Math.min(reconnectAttempts, 3));
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Exponential backoff, max 10s
|
||||||
|
setTimeout(connectWebSocket, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,17 +188,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
{% if participant %}
|
|
||||||
// Send heartbeat every 10 seconds
|
|
||||||
setInterval(function() {
|
|
||||||
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
|
|
||||||
lobbySocket.send(JSON.stringify({
|
|
||||||
type: 'heartbeat',
|
|
||||||
participant_id: '{{ participant.participant_id }}'
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, heartbeatDelay);
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
// Initial connection
|
// Initial connection
|
||||||
lobbySocket = connectWebSocket();
|
lobbySocket = connectWebSocket();
|
||||||
|
|||||||
Reference in New Issue
Block a user