Compare commits

..

1 Commits

Author SHA1 Message Date
juhnsa
defbc5b95d Umbenennen des Projektordners 2025-03-15 21:21:53 +01:00
102 changed files with 203 additions and 5975 deletions

4
.gitignore vendored
View File

@@ -3,7 +3,3 @@ __pycache__
db.sqlite3
tailwindcss.exe
t-style.css
dump.rdb
media
node_modules
staticfiles

View File

@@ -7,7 +7,6 @@
3. [Superuser erstellen](#superuser-erstellen)
4. [Tailwind-Nutzung](#tailwind-nutzung)
5. [Issue - Merge Request - Merge](#issue-mergerequest-merge)
6. [Konfiguration](#konfiguration)
## Initialisierung des Projekts
@@ -48,62 +47,16 @@ Um das Projekt erfolgreich zu starten, folge diesen Schritten:
### Entwicklungsserver starten
Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne:
Um den Server zu starten, verwende einen der folgenden Befehle:
1. Aktiviere die virtuelle Umgebung:
```sh
source .venv/bin/activate # Linux
.venv\Scripts\activate # Windows
```
2. Starte den Daphne-Server:
```sh
# Im Verzeichnis 'django'
daphne -b 127.0.0.1 -p 8000 core.asgi:application
```
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
```
### Produktionsserver starten
Für den Produktivbetrieb verwenden wir Daphne als ASGI-Server, der sowohl HTTP als auch WebSocket-Verbindungen unterstützt:
1. Aktiviere die virtuelle Umgebung wie oben beschrieben
2. Sammle die statischen Dateien:
```sh
python3 core/manage.py collectstatic
```
3. Starte den Daphne-Server:
```sh
daphne -b 0.0.0.0 -p 8000 core.asgi:application
```
- `-b 0.0.0.0`: Bindet den Server an alle Netzwerk-Interfaces
- `-p 8000`: Port (anpassbar)
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!
- Standardport (8000):
```sh
python3 core/manage.py runserver
```
- Bestimmten Port (z. B. 9000):
```sh
python3 core/manage.py runserver 9000
```
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden!
@@ -147,10 +100,10 @@ npx @tailwindcss/cli -i <INPUT-DATEI> -o <OUTPUT-DATEI> --watch --minify
```sh
# unter Linux mit Tailwind Binärdatei:
npx tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
tailwindcss -i core/static/homepage/t-input.css -o core/static/homepage/t-style.css --watch --minify
# unter Windows mit NPM (in bash mit '/' statt '\'):
npx @tailwindcss/cli -i ./static/css/t-input.css -o ./static/css/t-style.css --watch --minify
# unter Windows mit NPM:
npx @tailwindcss/cli -i .\core\static\homepage\t-input.css -o .\core\static\homepage\t-style.css --watch --minify
```
## Issue - Merge Request - Merge
@@ -159,14 +112,6 @@ Hier der vereinbarte Arbeitsablauf:
1. Issue erstellen - das geht am einfachsten über die Webseite. Der Titel beschreibt kurz und klar, was angestrebt wird.
2. Merge Request anlegen - dadurch wird der Branch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden.
2. Merge Request anlegen - dadurch wird der Bransch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden.
3. Nach dem Hochladen kann der Merge in den Master durchgeführt werden.
## Konfiguration
Die Konfiguration des Projekts befindet sich im `config/qivip_config.json`.
| Variable | Bedeutung | Optionen |
| --- | --- | --- |
| ENABLE_RATING_SYSTEM | Soll das Bewertungssystem aktiviert werden? | true [Standard], false |

View File

@@ -1,6 +1,5 @@
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, authenticate
from .forms import RegisterForm
# Create your views here.
@@ -12,9 +11,8 @@ def register(response):
if response.method == "POST":
form = RegisterForm(response.POST)
if form.is_valid():
user=form.save()
login(response, user) #automatischer Login nach Registrierung
return redirect("accounts:home")
form.save()
return redirect("home")
else:
form = RegisterForm()

View File

@@ -1,3 +0,0 @@
{
"ENABLE_RATING_SYSTEM": true
}

View File

@@ -8,21 +8,9 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from play.routing import websocket_urlpatterns
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
websocket_urlpatterns
)
),
})
application = get_asgi_application()

View File

@@ -10,7 +10,6 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import json
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -28,9 +27,6 @@ DEBUG = True
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '*']
# Konfigurationsdatei für Projekt
with open(BASE_DIR / 'config' / 'qivip_config.json') as config_file:
QIVIP_CONFIG = json.load(config_file)
# Application definition
@@ -39,20 +35,17 @@ INSTALLED_APPS = [
'homepage.apps.HomepageConfig',
'components.apps.ComponentsConfig',
'accounts.apps.AccountsConfig',
'play',
'play.apps.PlayConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'django_cleanup',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -80,17 +73,6 @@ TEMPLATES = [
]
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)],
},
},
}
# Database
@@ -103,15 +85,6 @@ DATABASES = {
}
}
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
}
# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
@@ -148,22 +121,11 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / 'static']
# WhiteNoise configuration
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
WHITENOISE_SKIP_COMPRESS_EXTENSIONS = ['css', 'js']
# Return 404 instead of 500 for missing files
WHITENOISE_MISSING_FILE_ERRNO = None
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = '/account/login/'
import os
MEDIA_URL = '/media/' # URL, um auf Medien-Dateien zuzugreifen
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Speicherort für hochgeladene Dateien

View File

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

View File

@@ -1,14 +1,15 @@
from django.contrib import admin
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizCategory, QuizImage
from .models import QivipQuiz, QivipQuestion, QuizCategory, Tag
# Für das QivipQuiz Modell
class QivipQuizAdmin(admin.ModelAdmin):
list_display = ('name', 'user_id', 'status', 'category', 'creation_date', 'update_date') # Welche Felder sollen in der Übersicht angezeigt werden
search_fields = ('name', 'user_id__username', 'category__name') # Suchfelder
list_filter = ('status', 'category') # Filteroptionen
# Für das QivipQuestion Modell
class QivipQuestionAdmin(admin.ModelAdmin):
list_display = ('id', 'quiz_id', 'data', 'creation_date', 'update_date')
list_display = ('quiz_id', 'data', 'creation_date', 'update_date')
search_fields = ('data',)
list_filter = ('quiz_id',)
@@ -18,22 +19,13 @@ class QuizCategoryAdmin(admin.ModelAdmin):
search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)} # Erstelle automatisch den Slug basierend auf dem Namen
class QivipQuizFavoriteAdmin(admin.ModelAdmin):
list_display = ('user', 'favorite', 'quiz') # Welche Felder sollen in der Übersicht angezeigt werden
class QuizImageAdmin(admin.ModelAdmin):
list_display = ('image',)
class QuestionImageAdmin(admin.ModelAdmin):
list_display = ('image',)
# Für das Tag Modell
class TagAdmin(admin.ModelAdmin):
list_display = ('name',) # Zeige nur den Namen des Tags
search_fields = ('name',)
# Registrierung der Modelle im Admin
admin.site.register(QivipQuiz, QivipQuizAdmin)
admin.site.register(QivipQuestion, QivipQuestionAdmin)
admin.site.register(QuizCategory, QuizCategoryAdmin)
admin.site.register(QivipQuizFavorite, QivipQuizFavoriteAdmin)
admin.site.register(QuizImage, QuizImageAdmin)
admin.site.register(QuestionImage, QuestionImageAdmin)
admin.site.register(Tag, TagAdmin)

View File

@@ -4,28 +4,9 @@ from .models import QivipQuiz, QivipQuestion
class QuizForm(forms.ModelForm):
class Meta:
model = QivipQuiz
fields = ['name', 'description', 'status', 'category','difficulty','credits']
fields = ['name', 'description', 'status', 'category', 'tags',"difficulty"]
class QuestionForm(forms.ModelForm):
class Meta:
model = QivipQuestion
fields = ['quiz_id', 'data','time_per_question']
from django import forms
class QuizFilterForm(forms.Form):
search = forms.CharField(required=False, label="Suche", widget=forms.TextInput(attrs={
'class': 'flex flex-grow rounded-lg p-2' ,'placeholder': 'Suche ...',
}))
min_amout_questions = forms.IntegerField(required=False, min_value=1, label="Minimale Anzahl an Fragen", widget=forms.NumberInput(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full'
}))
max_amout_questions = forms.IntegerField(required=False, min_value=1, label="Maximale Anzahl an Fragen", widget=forms.NumberInput(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full'
}))
user = forms.CharField(required=False, label="von User", widget=forms.TextInput(attrs={
'class': 'border-2 border-gray-300 rounded-lg p-2 w-full'
}))
fields = ['quiz_id', 'data']

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

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 12:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0015_alter_qivipquiz_difficulty'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='status',
field=models.CharField(choices=[('öffentlich', 'Öffentlich'), ('versteckt', 'Versteckt'), ('privat', 'Privat')], default='öffentlich', max_length=10),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 12:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0016_alter_qivipquiz_status'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='difficulty',
field=models.CharField(choices=[('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('nicht gesetzt', 'nicht gesetzt')], default='nicht gesetzt', help_text='1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit', max_length=13),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 13:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0017_alter_qivipquiz_difficulty'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='description',
field=models.TextField(blank=True, default='In dem Quiz geht es um ...', max_length=150, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 13:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0018_alter_qivipquiz_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='description',
field=models.TextField(default='In dem Quiz geht es um ...', max_length=255, null=True),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 13:08
import library.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0019_alter_qivipquiz_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='description',
field=models.TextField(default='In dem Quiz geht es um ...', max_length=255, validators=[library.models.QivipQuiz.validate_description]),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 14:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0020_alter_qivipquiz_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='name',
field=models.CharField(max_length=100),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-16 14:35
import library.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0021_alter_qivipquiz_name'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='description',
field=models.TextField(default='In dem Quiz geht es um ...', max_length=200, validators=[library.models.QivipQuiz.validate_description]),
),
migrations.AlterField(
model_name='qivipquiz',
name='name',
field=models.CharField(max_length=75),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-21 17:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0022_alter_qivipquiz_description_alter_qivipquiz_name'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='quiz_images/'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 10:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0023_qivipquiz_image'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-03 16:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0024_alter_qivipquiz_image'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='modified_description',
field=models.TextField(blank=True, default='Das Quiz wurde modifiziert von:', editable=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 13:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0025_qivipquiz_modified_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='modified_description',
field=models.TextField(blank=True, editable=False),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 13:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0026_alter_qivipquiz_modified_description'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='creator_quiz', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 13:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0027_qivipquiz_creator'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='creator',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='creator_quiz', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-04 16:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0028_alter_qivipquiz_creator'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='credits',
field=models.TextField(blank=True),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 09:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0029_qivipquiz_credits'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='creator',
),
migrations.RemoveField(
model_name='qivipquiz',
name='modified_description',
),
migrations.AddField(
model_name='qivipquiz',
name='base_quiz',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_quizzes', to='library.qivipquiz'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 17:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0030_remove_qivipquiz_creator_and_more'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='quiz_id',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='library.qivipquiz'),
),
]

View File

@@ -1,37 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 19:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0031_alter_qivipquestion_quiz_id'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='average_rating',
field=models.FloatField(default=3.0),
),
migrations.AddField(
model_name='qivipquiz',
name='rating_count',
field=models.IntegerField(default=0),
),
migrations.CreateModel(
name='QuizRating',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('participant_id', models.CharField(max_length=200)),
('rating', models.IntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')])),
('created_at', models.DateTimeField(auto_now_add=True)),
('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ratings', to='library.qivipquiz')),
],
options={
'unique_together': {('quiz', 'participant_id')},
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-08 14:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more'),
]
operations = [
migrations.AddField(
model_name='qivipquestion',
name='time_per_question',
field=models.CharField(choices=[('10', 10), ('15', 15), ('30', 30), ('60', 60), ('90', 90), ('120', 120), ('180', 180), ('nicht gesetzt', 'nicht gesetzt')], default=30, help_text='Zeit pro Frage in Sekunden', max_length=13),
),
]

View File

@@ -1,20 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 13:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0032_qivipquiz_average_rating_qivipquiz_rating_count_and_more'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='tags',
),
migrations.DeleteModel(
name='Tag',
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-08 14:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0033_qivipquestion_time_per_question'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='time_per_question',
field=models.CharField(choices=[('10', 10), ('15', 15), ('30', 30), ('60', 60), ('90', 90), ('120', 120), ('180', 180)], default=30, help_text='Zeit pro Frage in Sekunden', max_length=13),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0033_remove_qivipquiz_tags_delete_tag'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='status',
field=models.CharField(choices=[('öffentlich', 'Öffentlich'), ('privat', 'Privat')], default='öffentlich', max_length=10),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-08 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0034_alter_qivipquestion_time_per_question'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='time_per_question',
field=models.CharField(choices=[('10', 10), ('15', 15), ('30', 30), ('60', 60), ('90', 90), ('120', 120), ('180', 180)], default=30, help_text='Zeit pro Frage in Sekunden', max_length=3),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0034_alter_qivipquiz_status'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='favorite',
field=models.BooleanField(default=False),
),
]

View File

@@ -1,32 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 16:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0035_qivipquiz_favorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='favorite',
),
migrations.CreateModel(
name='QuizFavorite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('favorite', models.BooleanField(default=False)),
('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.qivipquiz')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'quiz')},
},
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 16:05
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0036_remove_qivipquiz_favorite_quizfavorite'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel(
old_name='QuizFavorite',
new_name='QivipQuizFavorite',
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-10 17:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0037_rename_quizfavorite_qivipquizfavorite'),
]
operations = [
migrations.AlterField(
model_name='qivipquizfavorite',
name='quiz',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='library.qivipquiz'),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 12:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0038_alter_qivipquizfavorite_quiz'),
]
operations = [
migrations.AddField(
model_name='qivipquizfavorite',
name='favorite_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 13:15
import library.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0039_qivipquizfavorite_favorite_date'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='description',
field=models.TextField(default='In dem Quiz geht es um ...', max_length=200, validators=[library.models.QivipQuiz.validate_description], verbose_name='Beschreibung'),
),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 13:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0040_alter_qivipquiz_description'),
]
operations = [
migrations.AlterField(
model_name='qivipquiz',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz', to='library.quizcategory', verbose_name='Kategorie'),
),
migrations.AlterField(
model_name='qivipquiz',
name='difficulty',
field=models.CharField(choices=[('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('nicht gesetzt', 'nicht gesetzt')], default='nicht gesetzt', help_text='1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit', max_length=13, verbose_name='Schwierigkeit'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild'),
),
migrations.AlterField(
model_name='qivipquiz',
name='name',
field=models.CharField(max_length=75, verbose_name='Name des Quizzes'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0041_alter_qivipquiz_category_alter_qivipquiz_difficulty_and_more'),
]
operations = [
migrations.AddField(
model_name='qivipquiz',
name='favorite',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='library.qivipquizfavorite'),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 15:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0042_qivipquiz_favorite'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='favorite',
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 16:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0043_remove_qivipquiz_favorite'),
]
operations = [
migrations.RemoveField(
model_name='qivipquizfavorite',
name='favorite_date',
),
migrations.AddField(
model_name='qivipquiz',
name='favorite_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0044_remove_qivipquizfavorite_favorite_date_and_more'),
]
operations = [
migrations.RemoveField(
model_name='qivipquiz',
name='favorite_date',
),
migrations.AddField(
model_name='qivipquizfavorite',
name='favorite_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-11 16:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0045_remove_qivipquiz_favorite_date_and_more'),
]
operations = [
migrations.AlterField(
model_name='qivipquizfavorite',
name='favorite_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,14 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-18 11:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('library', '0035_alter_qivipquestion_time_per_question'),
('library', '0046_alter_qivipquizfavorite_favorite_date'),
]
operations = [
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-18 11:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0047_merge_20250418_1334'),
]
operations = [
migrations.AddField(
model_name='qivipquestion',
name='image',
field=models.ImageField(blank=True, height_field=900, max_length=200, null=True, upload_to='question_images/', verbose_name='Bild', width_field=1000),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-18 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0048_qivipquestion_image'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild'),
),
]

View File

@@ -1,38 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-22 16:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0049_alter_qivipquestion_image'),
]
operations = [
migrations.CreateModel(
name='QuestionImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild')),
],
),
migrations.CreateModel(
name='QuizImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild')),
],
),
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='questions', to='library.questionimage'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='quizzes', to='library.QuizImage'),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-23 12:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0050_questionimage_quizimages_alter_qivipquestion_image_and_more'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='library.questionimage'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='library.quizimage'),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-23 12:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0051_alter_qivipquestion_image_alter_qivipquiz_image'),
]
operations = [
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild'),
),
migrations.DeleteModel(
name='QuestionImage',
),
migrations.DeleteModel(
name='QuizImage',
),
]

View File

@@ -1,38 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-23 13:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0052_alter_qivipquestion_image_alter_qivipquiz_image_and_more'),
]
operations = [
migrations.CreateModel(
name='QuestionImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, null=True, upload_to='question_images/', verbose_name='Bild')),
],
),
migrations.CreateModel(
name='QuizImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(blank=True, max_length=256, null=True, upload_to='quiz_images/', verbose_name='Bild')),
],
),
migrations.AlterField(
model_name='qivipquestion',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='questions', to='library.questionimage'),
),
migrations.AlterField(
model_name='qivipquiz',
name='image',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='quizzes', to='library.quizimage'),
),
]

View File

@@ -2,22 +2,12 @@ from django.db import models
from django.contrib.auth.models import User
from django.utils.text import slugify
import uuid
from django.core.exceptions import ValidationError
class QuizImage(models.Model):
image = models.ImageField(max_length=256,verbose_name="Bild", upload_to='quiz_images/', blank=True, null=True) # Bild speichern in media/quiz_images/
class QuestionImage(models.Model):
image = models.ImageField(verbose_name="Bild", upload_to="question_images/", blank=True, null=True)
class QivipQuiz(models.Model):
STATUS_VALUES = {
"öffentlich": "Öffentlich",
#"versteckt": "Versteckt",
"privat": "Privat",
"public": "Öffentlich",
"hidden": "Versteckt",
"private": "Privat",
}
DIFFICULTY_VALUES = {
"1": "1",
@@ -25,106 +15,41 @@ class QivipQuiz(models.Model):
"3": "3",
"4": "4",
"5": "5",
"nicht gesetzt":"nicht gesetzt",
"not defined":"nicht gesetzt",
}
def validate_description(value):
if value.strip() == "In dem Quiz geht es um ...":
raise ValidationError("Bitte gib eine aussagekräftige Beschreibung ein.")
image = models.ForeignKey(
QuizImage,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="quizzes"
)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
user_id = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quiz')
#creator = models.ForeignKey(User, on_delete=models.SET_NULL,blank=True, null=True, related_name='creator_quiz')
base_quiz = models.ForeignKey('self', on_delete=models.SET_NULL,blank=True, null=True, related_name='child_quizzes')
creation_date = models.DateTimeField(auto_now_add=True)
update_date = models.DateTimeField(auto_now=True)
status = models.CharField(max_length=10, choices=list(STATUS_VALUES.items()), default="öffentlich")
category = models.ForeignKey('QuizCategory',verbose_name="Kategorie", related_name='quiz', on_delete=models.CASCADE)
name = models.CharField(max_length=75,verbose_name="Name des Quizzes")
description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...", verbose_name="Beschreibung")
difficulty= models.CharField(verbose_name="Schwierigkeit", max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit")
credits=models.TextField(blank=True, editable=True)
average_rating = models.FloatField(default=3.0)
rating_count = models.IntegerField(default=0)
status = models.CharField(max_length=10, choices=list(STATUS_VALUES.items()), default="public")
category = models.ForeignKey('QuizCategory', related_name='quiz', on_delete=models.CASCADE)
tags = models.ManyToManyField('Tag', blank=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, null=True, default="In dem Quiz geht es um ...")
difficulty= models.CharField(max_length=11, choices=list(DIFFICULTY_VALUES.items()), default="not defined", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit")
def __str__(self):
return self.name
class QivipQuizFavorite(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) # Verknüpfung zu User
quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='favorites') # Verknüpfung zu Quiz
favorite = models.BooleanField(default=False) # Favoritenstatus
favorite_date = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('user', 'quiz') # Sicherstellen, dass es nur ein Favorit pro User und Quiz gibt
# Create your models here.
class QivipQuestion(models.Model):
TIME_VALUES = {
"10": 10,
"15": 15,
"30": 30,
"60": 60,
"90": 90,
"120": 120,
"180": 180,
}
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='questions')
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='question')
creation_date = models.DateTimeField(auto_now_add=True)
update_date = models.DateTimeField(auto_now=True)
data = models.TextField()
time_per_question = models.CharField(max_length=3, choices=list(TIME_VALUES.items()), default=30, help_text="Zeit pro Frage in Sekunden")
image = models.ForeignKey(
QuestionImage,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="questions"
)
def __str__(self):
return self.data[:50]
class Tag(models.Model):
name = models.CharField(max_length=100, unique=True)
class QuizRating(models.Model):
quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='ratings')
participant_id = models.CharField(max_length=200)
rating = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)])
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('quiz', 'participant_id')
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new:
# Update quiz average rating
quiz = self.quiz
total_ratings = quiz.ratings.count()
avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg']
quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0
quiz.rating_count = total_ratings
quiz.save()
def __str__(self):
return self.name
class QuizCategory(models.Model):
name = models.CharField(max_length=100, unique=True)

View File

@@ -1,8 +1,6 @@
from django.urls import path
from django.conf import settings
from . import views
from django.conf.urls.static import static
app_name = 'library'
urlpatterns = [
@@ -14,7 +12,4 @@ urlpatterns = [
path('question/new/', views.new_question, name='new_question'),
path('question/edit/<int:pk>/', views.edit_question, name='edit_question'),
path('question/delete/<int:pk>/', views.delete_question, name='delete_question'),
path('detail/copy/<int:pk>/', views.copy_quiz, name='copy_quiz'),
path('favorite_quiz/<int:pk>/', views.favorite_quiz, name='favorite_quiz'),
]

View File

@@ -1,221 +1,29 @@
from django.utils import timezone
import uuid
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.urls import reverse
from django.contrib import messages
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizImage
from .models import QivipQuiz, QivipQuestion
from .forms import QuizForm, QuestionForm
import json
from django.core.paginator import Paginator
from .models import QivipQuiz
from .forms import QuizFilterForm
from django.db.models import Count
from django.contrib.auth.models import User
# Übersicht aller Quizze
def overview_quiz(request):
# Filter
form = QuizFilterForm(request.GET)
try:
favorite_quizzes_data = QivipQuizFavorite.objects.filter(user=request.user, favorite=True).order_by("-favorite_date")
favorite_quiz_ids = favorite_quizzes_data.values_list('quiz', flat=True)
favorite_quizzes = QivipQuiz.objects.filter(id__in=favorite_quiz_ids)
except:
favorite_quizzes_data = QivipQuizFavorite.objects.none()
favorite_quizzes = QivipQuizFavorite.objects.none()
favorite_quiz_ids = favorite_quizzes.none()
favorite_quizzes = QivipQuiz.objects.none()
# Initialize querysets
if request.user.is_authenticated:
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions'))
favorite_quizzes = favorite_quizzes.filter(id__in=favorite_quiz_ids).annotate(max_amout_questions=Count('questions'))
filter_other_quizzes = QivipQuiz.objects.exclude(user_id=request.user)
else:
favorite_quizzes = QivipQuiz.objects.none()
filter_my_quizzes = QivipQuiz.objects.none()
filter_other_quizzes = QivipQuiz.objects.all()
# 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():
search = form.cleaned_data.get('search')
username= form.cleaned_data.get('user')
max_amout_questions= form.cleaned_data.get('max_amout_questions')
min_amout_questions= form.cleaned_data.get('min_amout_questions')
filters = {}
if username:
try:
user = User.objects.get(username=username)
filters['user_id'] = user.id
except User.DoesNotExist:
favorite_quizzes = QivipQuiz.objects.none()
filter_my_quizzes = QivipQuiz.objects.none()
filter_other_quizzes=QivipQuiz.objects.none()
if search:
filters['name__icontains'] = search
if min_amout_questions:
filters['max_amout_questions__gte'] = min_amout_questions
if max_amout_questions:
filters['max_amout_questions__lte'] = max_amout_questions
try:
filter_other_quizzes = filter_other_quizzes.filter(**filters)
except:
filter_other_quizzes=QivipQuiz.objects.none()
try:
filter_my_quizzes = filter_my_quizzes.filter(**filters)
except:
filter_my_quizzes=QivipQuiz.objects.none()
try:
favorite_quizzes = favorite_quizzes.filter(**filters)
except:
favorite_quizzes=QivipQuiz.objects.none()
"""
if search: # Suche nach Namen
filter_other_quizzes = filter_other_quizzes.filter(name__icontains=search)
filter_my_quizzes = filter_my_quizzes.filter(name__icontains=search)
if min_amout_questions:
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=min_amout_questions)
filter_my_quizzes = filter_my_quizzes.filter(max_amout_questions__gte=min_amout_questions) if filter_my_quizzes else QivipQuiz.objects.none()
if max_amout_questions:
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__lte=max_amout_questions)
try:
filter_my_quizzes =filter_my_quizzes.filter(max_amout_questions__lte=max_amout_questions)
except:
filter_my_quizzes = QivipQuiz.objects.none()
if username:
try:
user = User.objects.get(username=username) # Benutzer anhand des Namens holen
filter_other_quizzes = filter_other_quizzes.filter(user_id=user.id)
filter_my_quizzes = filter_my_quizzes.filter(user_id=user.id) # Nach der user_id filtern
except User.DoesNotExist:
filter_other_quizzes = QivipQuiz.objects.none() # Falls User nicht existiert, leere Query zurückgeben
filter_my_quizzes = QivipQuiz.objects.none()
try:
filter_other_quizzes=filter_other_quizzes.exclude(user_id=request.user)
except:
filter_other_quizzes=filter_other_quizzes
"""
# 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)
try:
favorite_quizzes = favorite_quizzes.filter(
favorites__user=request.user, # Filtere nach dem User
favorites__favorite=True # Filtere nach favorisierten Quizzes
).order_by('-favorites__favorite_date')
except:
pass
# Pagination for favorite_quizzes
favorite_quizzes_paginator = Paginator(favorite_quizzes, 8) # 8 quizzes per page
try:
favorite_quizzes = favorite_quizzes_paginator.page(request.GET.get('my_page', 1))
except:
favorite_quizzes = favorite_quizzes_paginator.page(1)
context = {
'show_search': True,
'favorite_quiz_ids': favorite_quiz_ids,
'favorite_quizzes':favorite_quizzes,
'my_quizzes': my_quizzes,
'other_quizzes': other_quizzes,
'form': form,
}
return render(request, 'library/overview_quiz.html', context)
# Neues Quiz erstellen
@login_required
def favorite_quiz(request, pk):
quiz= get_object_or_404(QivipQuiz, pk=pk)
favorite = QivipQuizFavorite.objects.filter(user=request.user, quiz=quiz).first()
if favorite:
favorite.favorite=not favorite.favorite
if favorite.favorite==True:
favorite.favorite_date = timezone.now()
statement="Das Quiz '"+ favorite.quiz.name+ "' wurde erfolgreich zu den Favoriten hinzugefügt!"
messages.success(request,statement )
favorite.save()
else:
favorite.favorite_date = None
favorite.delete()
statement="Das Quiz '"+ favorite.quiz.name+ "' wurde erfolgreich von den Favoriten entfernt!"
messages.error(request, statement)
else:
QivipQuizFavorite.objects.create(user=request.user, quiz=quiz, favorite=True, favorite_date=timezone.now())
return redirect(request.META.get('HTTP_REFERER', 'library:overview_quiz'))
def overview_quiz(request):
quizzes = QivipQuiz.objects.filter(user_id=request.user)
return render(request, 'library/overview_quiz.html', {'quizzes': quizzes})
# Neues Quiz erstellen
@login_required
def new_quiz(request):
if request.method == 'POST':
form = QuizForm(request.POST, request.FILES)
form = QuizForm(request.POST)
if form.is_valid():
quiz = form.save(commit=False)
try:
quiz_image=request.FILES['quiz_image']
imagequiz=QuizImage.objects.create(image=quiz_image)
quiz.image=imagequiz
except:
pass
quiz.user_id = request.user
#quiz.creator = request.user
quiz.save()
#form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
return redirect('library:detail_quiz', pk=quiz.pk)
form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
return redirect('library:edit_quiz', pk=quiz.pk)
else:
form = QuizForm()
return render(request, 'library/form.html', {'form': form})
@@ -225,33 +33,10 @@ def new_quiz(request):
def edit_quiz(request, pk):
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
if request.method == 'POST':
form = QuizForm(request.POST, instance=quiz, files=request.FILES)
form = QuizForm(request.POST, instance=quiz)
if form.is_valid():
if 'reset_image' in request.POST:
quiz.image = None
try:
quiz_image=request.FILES['quiz_image']
imagequiz=QuizImage.objects.create(image=quiz_image)
quiz.image=imagequiz
except:
pass
form.save()
for img in QuizImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:detail_quiz', pk=pk)
#return modified(request, pk)
return redirect('library:overview_quiz')
else:
form = QuizForm(instance=quiz)
return render(request, 'library/form.html', {'form': form})
@@ -262,28 +47,15 @@ def delete_quiz(request, pk):
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
if request.method == 'POST':
quiz.delete()
for img in QuizImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
for img in QuestionImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:overview_quiz')
return render(request, 'library/delete_confirmation.html', {'object': quiz})
# Quiz anzeigen
@login_required
def detail_quiz(request, pk):
#quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
quiz = get_object_or_404(QivipQuiz, pk=pk)
show_answers = request.GET.get('show_answers', 'false').lower() == 'true'
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
questions = QivipQuestion.objects.filter(quiz_id=quiz)
# Parse JSON data for each question
for question in questions:
if question.data:
@@ -291,46 +63,10 @@ def detail_quiz(request, pk):
context = {
'quiz': quiz,
'questions': questions,
'detail':show_answers,
'questions': questions
}
return render(request, 'library/detail_quiz.html', context)
@login_required
def copy_quiz(request, pk):
original_quiz = get_object_or_404(QivipQuiz, pk=pk)
# Quiz kopieren (ohne ID, damit ein neues Objekt erstellt wird)
# Quiz kopieren (ohne ID, damit ein neues Objekt erstellt wird)
new_quiz = original_quiz # Kopie erstellen (aber Achtung: Noch gleiche Referenz!)
new_quiz.pk = None # Setzt die ID auf None, damit Django es als neues Objekt erkennt
new_quiz.uuid = uuid.uuid4() # Neue UUID generieren
new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User
new_quiz.name += " - Kopie"
new_quiz.base_quiz_id = pk
new_quiz.rating_count = 0
new_quiz.average_rating = 3.0
new_quiz.save() # Speichern als neues Objekt
# Optional: Beschreibung anpassen
# Alle zugehörigen Fragen kopieren
questions = QivipQuestion.objects.filter(quiz_id=pk)
for question in questions:
question.uuid = uuid.uuid4()
question.pk = None # Setze pk auf None, damit es als neues Objekt gespeichert wird
question.quiz_id = original_quiz # Verknüpfe mit dem neuen Quiz
question.data = question.data # `data`-Feld wird explizit übernommen # Verknüpfe mit dem neuen Quiz
question.save()
messages.success(request, "Quiz wurde erfolgreich kopiert!")
return redirect('library:detail_quiz', pk=new_quiz.pk)
# Übersicht aller Fragen
@login_required
def question_list(request):
@@ -404,17 +140,7 @@ def new_question(request):
question = QivipQuestion()
question.data = json.dumps(json_data)
question.quiz_id = quiz
try:
question_image=request.FILES['question_image']
imagequestion=QuestionImage.objects.create(image=question_image)
question.image=imagequestion
except:
pass
question.time_per_question = request.POST.get('time_per_question', '30')
question.save()
#return modified(request, pk=quiz.pk)
return redirect('library:detail_quiz', pk=quiz.pk)
else:
# Initialize empty question data for new questions
@@ -427,7 +153,6 @@ def new_question(request):
{'value': 'Falsch', 'is_correct': False}
]
}
standard_time_per_question = 30
elif question_type == 'multiple_choice':
question_data = {
'type': question_type,
@@ -437,14 +162,12 @@ def new_question(request):
{'value': '', 'is_correct': False}
]
}
standard_time_per_question = 30
template_name = f'library/question/question_{question_type}.html'
context = {
'question': question_data,
'quiz_id': quiz_id,
'quiz': quiz,
'standard_time_per_question': standard_time_per_question,
'quiz': quiz
}
return render(request, template_name, context)
@@ -453,8 +176,6 @@ def new_question(request):
@login_required
def edit_question(request, pk):
question = get_object_or_404(QivipQuestion, pk=pk, quiz_id__user_id=request.user)
if 'reset_ques_image' in request.POST:
question.image = None
if question.quiz_id.user_id != request.user:
return redirect('library:question_list')
@@ -462,7 +183,6 @@ def edit_question(request, pk):
try:
question_data = json.loads(question.data) if question.data else {}
question_type = question_data.get('type', 'multiple_choice')
question_image = question.image
# For true/false questions, get the correct answer from the options
if question_type == 'true_false':
@@ -507,12 +227,6 @@ def edit_question(request, pk):
if request.method == 'POST':
question_text = request.POST.get('question', '')
try:
question_image=request.FILES['question_image']
imagequestion=QuestionImage.objects.create(image=question_image)
question.image=imagequestion
except Exception:
pass
# Handle different question types
if question_type == 'true_false':
@@ -554,32 +268,18 @@ def edit_question(request, pk):
}
question.data = json.dumps(json_data)
question.time_per_question = request.POST.get('time_per_question', '30')
question.save()
for img in QuestionImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
#return modified(request, question.quiz_id.pk)
return redirect('library:detail_quiz', pk=question.quiz_id.pk)
else:
template_name = f'library/question/question_{question_type}.html'
context = {
'question': {'data': question_data}, # Wrap in data structure expected by template
'quiz_id': question.quiz_id.pk,
'quiz': question.quiz_id,
'time_per_question': question.time_per_question,
'image': question_image,
'quiz': question.quiz_id
}
return render(request, template_name, context)
# Frage löschen
@login_required
def delete_question(request, pk):
@@ -591,11 +291,5 @@ def delete_question(request, pk):
if request.method == 'POST':
question.delete()
for img in QuestionImage.objects.all():
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:detail_quiz', pk=question.quiz_id.pk)
return render(request, 'library/delete_confirmation.html', {'object': question})

View File

@@ -1,35 +0,0 @@
{
"name": "django",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "django",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"swiper": "^11.2.6"
}
},
"node_modules/swiper": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.6.tgz",
"integrity": "sha512-8aXpYKtjy3DjcbzZfz+/OX/GhcU5h+looA6PbAzHMZT6ESSycSp9nAjPCenczgJyslV+rUGse64LMGpWE3PX9Q==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
}
}
}

View File

@@ -1,15 +0,0 @@
{
"name": "django",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"swiper": "^11.2.6"
}
}

View File

@@ -1,6 +1,3 @@
from django.contrib import admin
from .models import QuizGame, QuizGameParticipant
# Register your models here.
admin.site.register(QuizGame)
admin.site.register(QuizGameParticipant)

View File

@@ -1,527 +0,0 @@
import json
import asyncio
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from datetime import timedelta
from django.urls import reverse
from .models import QuizGame, QuizGameParticipant
class GameConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.game_group_name = f'game_{self.join_code}'
# Join game group
await self.channel_layer.group_add(
self.game_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave game group
await self.channel_layer.group_discard(
self.game_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'submit_answer':
participant_id = text_data_json['participant_id']
answer = text_data_json['answer']
time_remaining = text_data_json.get('time_remaining', 0)
# Save answer and update participant score
await self.save_answer(participant_id, answer, time_remaining)
# Notify host about the new answer
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_answer',
'answer': answer
}
)
elif message_type == 'get_answer_stats':
stats = await self.get_answer_stats()
await self.send(text_data=json.dumps({
'type': 'answer_stats',
'stats': stats
}))
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'next_question':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.advance_to_next_question()
elif message_type == 'finish_game':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.finish_game()
elif message_type == 'advance_to_scores':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.show_scores()
async def participant_answer(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_answer',
'answer': event['answer']
}))
async def participant_list_update(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_list_update',
'participants': event['participants']
}))
async def game_state_update(self, event):
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
@database_sync_to_async
def verify_host(self, host_id):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
return quiz_game.host_id == host_id
except QuizGame.DoesNotExist:
return False
@database_sync_to_async
def save_answer(self, participant_id, answer_index, time_remaining):
try:
participant = QuizGameParticipant.objects.get(
quiz_game__join_code=self.join_code,
participant_id=participant_id
)
quiz_game = participant.quiz_game
current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
# Check if answer is correct
is_correct = False
if answer_index >= 0: # -1 means timeout
is_correct = current_question.data['options'][answer_index]['is_correct']
# Calculate score based on correctness and time
score = 0
if is_correct:
base_score = 1000
time_factor = time_remaining / 30000 # 30 seconds max
score = int(base_score * (0.5 + 0.5 * time_factor))
participant.score += score
participant.last_answer_correct = is_correct
participant.save()
return True
except (QuizGameParticipant.DoesNotExist, IndexError):
return False
@database_sync_to_async
def get_answer_stats(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game)
# Count answers for each option
stats = {}
for participant in participants:
if participant.last_answer is not None:
stats[participant.last_answer] = stats.get(participant.last_answer, 0) + 1
return stats
except QuizGame.DoesNotExist:
return {}
@database_sync_to_async
def get_participants(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game)
return [
{
'id': p.participant_id,
'display_name': p.display_name,
'score': p.score
}
for p in participants
]
except QuizGame.DoesNotExist:
return []
@database_sync_to_async
def _advance_to_next_question(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz = quiz_game.quiz_id
# Move to next question
quiz_game.current_question_index += 1
# Check if we've reached the end
if quiz_game.current_question_index >= quiz.questions.count():
quiz_game.current_state = 'finished'
quiz_game.save()
return 'finished'
else:
# Update game state and start time
quiz_game.current_state = 'question'
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return 'question'
except QuizGame.DoesNotExist:
return None
async def advance_to_next_question(self):
result = await self._advance_to_next_question()
if result == 'finished':
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)
elif result == 'question':
# Notify clients to redirect to next question
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'next_question',
'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _show_scores(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'scores'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def show_scores(self):
success = await self._show_scores()
if success:
# Notify clients to redirect to scores page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'show_scores',
'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _finish_game(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'finished'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def finish_game(self):
success = await self._finish_game()
if success:
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)
class LobbyConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.room_group_name = f'game_{self.join_code}'
self.heartbeat_task = None
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
# Start heartbeat check
self.heartbeat_task = asyncio.create_task(self.check_participants_heartbeat())
# Send current participants list
participants = await self.get_participants()
await self.send(text_data=json.dumps({
'type': 'participant_list',
'participants': participants
}))
async def disconnect(self, close_code):
# Cancel heartbeat task
if self.heartbeat_task:
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
# Leave room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'heartbeat':
# Update participant's last activity timestamp
participant_id = text_data_json.get('participant_id')
if participant_id:
await self._update_participant_heartbeat(participant_id)
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'start_game':
# Verify sender is host
sender_id = text_data_json.get('host_id')
if await self._is_host(sender_id):
# Update game state and notify all participants
await self._start_game()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': 'start_game',
'redirect_url': f'/play/game/{self.join_code}/0'
}
)
async def game_state_update(self, event):
# Send game state update to WebSocket
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
@database_sync_to_async
def _start_game(self):
game = QuizGame.objects.get(join_code=self.join_code)
game.current_state = 'question'
game.current_question_index = 0
game.question_start_time = timezone.now()
game.save()
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'heartbeat':
# Update participant's last activity timestamp
participant_id = text_data_json.get('participant_id')
if participant_id:
await self._update_participant_heartbeat(participant_id)
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'start_game':
# Verify sender is host
sender_id = text_data_json.get('host_id')
if await self._is_host(sender_id):
# Update game state and notify all participants
await self._start_game()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': 'start_game',
'redirect_url': f'/play/game/{self.join_code}/0'
}
)
elif message_type == 'submit_answer':
participant_id = text_data_json.get('participant_id')
answer = text_data_json.get('answer')
time_remaining = text_data_json.get('time_remaining')
if participant_id:
score = await self._process_answer(participant_id, answer, time_remaining)
await self.send(text_data=json.dumps({
'type': 'answer_processed',
'score': score
}))
elif message_type == 'next_question':
sender_id = text_data_json.get('host_id')
if await self._is_host(sender_id):
next_state = await self._advance_game_state()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': next_state['action'],
'redirect_url': next_state['redirect_url']
}
)
async def participant_list_update(self, event):
participants = event['participants']
await self.send(text_data=json.dumps({
'type': 'participant_list',
'participants': participants
}))
@database_sync_to_async
def get_participants(self):
game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=game)
return [{'id': str(p.participant_id), 'name': p.display_name} for p in participants]
@database_sync_to_async
def _update_participant_heartbeat(self, participant_id):
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
participant.save() # This will update last_heartbeat due to auto_now=True
except QuizGameParticipant.DoesNotExist:
pass
@database_sync_to_async
def _is_host(self, host_id):
try:
return QuizGame.objects.filter(join_code=self.join_code, host_id=host_id).exists()
except QuizGame.DoesNotExist:
return False
@database_sync_to_async
def _start_game(self):
game = QuizGame.objects.get(join_code=self.join_code)
game.current_state = 'question'
game.current_question_index = 0
game.question_start_time = timezone.now()
game.save()
@database_sync_to_async
def _process_answer(self, participant_id, answer, time_remaining):
game = QuizGame.objects.get(join_code=self.join_code)
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
question = game.quiz_id.questions.all()[game.current_question_index]
# Load question data
question_data = json.loads(question.data)
correct_answer = question_data.get('correct_answer')
# Calculate score based on correctness and time
score = 0
if answer == correct_answer:
# Base score for correct answer + bonus for speed
score = 1000 + int(time_remaining * 10) # 10 points per remaining second
participant.score += score
participant.save()
return score
@database_sync_to_async
def _advance_game_state(self):
game = QuizGame.objects.get(join_code=self.join_code)
total_questions = game.quiz_id.questions.count()
if game.current_state == 'question':
game.current_state = 'scores'
game.save()
return {
'action': 'show_scores',
'redirect_url': f'/play/game/{self.join_code}/scores'
}
elif game.current_state == 'scores':
if game.current_question_index + 1 < total_questions:
game.current_state = 'question'
game.current_question_index += 1
game.question_start_time = timezone.now()
game.save()
return {
'action': 'next_question',
'redirect_url': f'/play/game/{self.join_code}/{game.current_question_index}'
}
else:
game.current_state = 'finished'
game.save()
return {
'action': 'game_finished',
'redirect_url': f'/play/game/{self.join_code}/finished'
}
@database_sync_to_async
def _remove_inactive_participants(self):
# Remove participants who haven't sent a heartbeat in the last 30 seconds
timeout = timezone.now() - timedelta(seconds=30)
quiz_game = QuizGame.objects.get(join_code=self.join_code)
QuizGameParticipant.objects.filter(
quiz_game=quiz_game,
last_heartbeat__lt=timeout
).delete()
async def check_participants_heartbeat(self):
while True:
try:
await asyncio.sleep(10) # Check every 10 seconds
await self._remove_inactive_participants()
# Send updated participant list
participants = await self.get_participants()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
except asyncio.CancelledError:
break
except Exception as e:
print(f'Error in heartbeat check: {e}')
await asyncio.sleep(10) # Wait before retrying

View File

@@ -1,3 +0,0 @@
from .game import GameConsumer
__all__ = ['GameConsumer']

View File

@@ -1,391 +0,0 @@
import json
import asyncio
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from django.urls import reverse
from play.models import QuizGame, QuizGameParticipant, QuizAnswer
from django.conf import settings
class GameConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.game_group_name = f'game_{self.join_code}'
# Join game group
await self.channel_layer.group_add(
self.game_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
# Leave game group
await self.channel_layer.group_discard(
self.game_group_name,
self.channel_name
)
# Check if game should be deleted
await self.cleanup_game()
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message_type = text_data_json['type']
if message_type == 'ping':
# Respond with pong to keep connection alive
await self.send(text_data=json.dumps({
'type': 'pong'
}))
return
if message_type == 'leave_game':
participant_id = text_data_json.get('participant_id')
if participant_id:
try:
# Get participant info before deletion
participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id)
display_name = participant.display_name
join_code = self.join_code
# Delete participant
await database_sync_to_async(participant.delete)()
# Update participants list
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
# Send redirect to home
await self.send(text_data=json.dumps({
'type': 'redirect',
'url': '/'
}))
except QuizGameParticipant.DoesNotExist:
pass
return
if message_type == 'submit_rating':
participant_id = text_data_json['participant_id']
rating = text_data_json['rating']
success = await self.save_rating(participant_id, rating)
if success:
await self.send(text_data=json.dumps({
'type': 'rating_confirmed'
}))
elif message_type == 'submit_answer':
participant_id = text_data_json['participant_id']
answer = text_data_json['answer']
time_remaining = text_data_json.get('time_remaining', 0)
# Save answer and update participant score
await self.save_answer(participant_id, answer, time_remaining)
# Notify host about the new answer
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_answer',
'answer': answer
}
)
elif message_type == 'start_game':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.advance_to_next_question()
elif message_type == 'get_answer_stats':
stats = await self.get_answer_stats()
await self.send(text_data=json.dumps({
'type': 'answer_stats',
'stats': stats
}))
elif message_type == 'update_participants':
participants = await self.get_participants()
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'participant_list_update',
'participants': participants
}
)
elif message_type == 'next_question':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.advance_to_next_question()
elif message_type == 'finish_game':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.finish_game()
elif message_type == 'advance_to_scores':
host_id = text_data_json['host_id']
if await self.verify_host(host_id):
await self.show_scores()
async def participant_answer(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_answer',
'answer': event['answer']
}))
async def participant_list_update(self, event):
await self.send(text_data=json.dumps({
'type': 'participant_list_update',
'participants': event['participants']
}))
async def player_left(self, event):
await self.send(text_data=json.dumps({
'type': 'player_left',
'player_name': event['player_name'],
'was_kicked': event.get('was_kicked', False)
}))
async def game_state_update(self, event):
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
@database_sync_to_async
def verify_host(self, host_id):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
return quiz_game.host_id == host_id
except QuizGame.DoesNotExist:
return False
@database_sync_to_async
def save_answer(self, participant_id, answer_index, time_remaining):
try:
participant = QuizGameParticipant.objects.get(
quiz_game__join_code=self.join_code,
participant_id=participant_id
)
quiz_game = participant.quiz_game
current_question = quiz_game.quiz_id.questions.all()[quiz_game.current_question_index]
# Check if answer is correct
is_correct = False
if answer_index >= 0: # -1 means timeout
question_data = json.loads(current_question.data)
is_correct = question_data['options'][answer_index]['is_correct']
# Calculate score based on correctness and time
score = 0
if is_correct:
base_score = 1000
time_factor = time_remaining / 30000 # 30 seconds max
score = int(base_score * (0.5 + 0.5 * time_factor))
# Update or create answer in QuizAnswer table
answer_obj, created = QuizAnswer.objects.get_or_create(
participant=participant,
question_index=quiz_game.current_question_index,
defaults={
'answer_index': answer_index,
'is_correct': is_correct,
'score': score,
'time_remaining': time_remaining
}
)
if not created:
answer_obj.answer_index = answer_index
answer_obj.is_correct = is_correct
answer_obj.score = score
answer_obj.time_remaining = time_remaining
answer_obj.save()
participant.score += score
participant.last_answer_correct = is_correct
participant.save()
return True
except (QuizGameParticipant.DoesNotExist, IndexError):
return False
@database_sync_to_async
def get_answer_stats(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
# Count answers for each option in the current question
stats = {}
answers = QuizAnswer.objects.filter(
participant__quiz_game=quiz_game,
question_index=quiz_game.current_question_index
)
for answer in answers:
if answer.answer_index >= 0: # Ignore timeouts (-1)
stats[answer.answer_index] = stats.get(answer.answer_index, 0) + 1
return stats
except QuizGame.DoesNotExist:
return {}
@database_sync_to_async
def save_rating(self, participant_id, rating):
if not settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']:
return False
from library.models import QuizRating
try:
game = QuizGame.objects.get(join_code=self.join_code)
participant = QuizGameParticipant.objects.get(
quiz_game=game,
participant_id=participant_id
)
# Update or create rating
rating_obj, created = QuizRating.objects.get_or_create(
quiz=game.quiz_id,
participant_id=participant_id,
defaults={'rating': rating}
)
if not created:
rating_obj.rating = rating
rating_obj.save()
return True
except (QuizGame.DoesNotExist, QuizGameParticipant.DoesNotExist):
return False
@database_sync_to_async
def cleanup_game(self):
try:
game = QuizGame.objects.get(join_code=self.join_code)
active_participants = QuizGameParticipant.objects.filter(
quiz_game=game
).count()
if active_participants == 0:
game.delete()
except QuizGame.DoesNotExist:
pass
@database_sync_to_async
def get_participants(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game)
return [
{
'id': p.participant_id,
'display_name': p.display_name,
'score': p.score
}
for p in participants
]
except QuizGame.DoesNotExist:
return []
@database_sync_to_async
def _advance_to_next_question(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz = quiz_game.quiz_id
# Move to next question
quiz_game.current_question_index += 1
# Check if we've reached the end
if quiz_game.current_question_index >= quiz.questions.count():
quiz_game.current_state = 'finished'
quiz_game.save()
return 'finished'
else:
# Update game state and start time
quiz_game.current_state = 'question'
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return 'question'
except QuizGame.DoesNotExist:
return None
async def advance_to_next_question(self):
result = await self._advance_to_next_question()
if result == 'finished':
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)
elif result == 'question':
# Notify clients to redirect to next question
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'next_question',
'redirect_url': reverse('play:question', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _show_scores(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'scores'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def show_scores(self):
success = await self._show_scores()
if success:
# Notify clients to redirect to scores page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'show_scores',
'redirect_url': reverse('play:scores', kwargs={'join_code': self.join_code})
}
)
@database_sync_to_async
def _finish_game(self):
try:
quiz_game = QuizGame.objects.get(join_code=self.join_code)
quiz_game.current_state = 'finished'
quiz_game.save()
return True
except QuizGame.DoesNotExist:
return False
async def finish_game(self):
success = await self._finish_game()
if success:
# Notify clients to redirect to finished page
await self.channel_layer.group_send(
self.game_group_name,
{
'type': 'game_state_update',
'action': 'finish_game',
'redirect_url': reverse('play:finished', kwargs={'join_code': self.join_code})
}
)

View File

@@ -1,235 +0,0 @@
from channels.generic.websocket import AsyncWebsocketConsumer
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):
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
async def connect(self):
self.join_code = self.scope['url_route']['kwargs']['join_code']
self.room_group_name = f'lobby_{self.join_code}'
# Get participant ID from session
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)
# Join room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
# Send initial participants list
await self.update_participants()
async def disconnect(self, close_code):
# Leave room group
await self.channel_layer.group_discard(
self.room_group_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):
data = json.loads(text_data)
message_type = data.get('type')
print(f'Received message type: {message_type}')
if message_type == 'ping':
# Respond with pong to keep connection alive
await self.send(text_data=json.dumps({
'type': 'pong'
}))
return
elif message_type in ['leave_game', 'kick_player']:
participant_id = data.get('participant_id')
if participant_id:
try:
# Get participant info before deletion
participant = await database_sync_to_async(QuizGameParticipant.objects.get)(participant_id=participant_id)
display_name = participant.display_name
# For kick_player, verify that the request comes from the host
if message_type == 'kick_player':
game = await database_sync_to_async(lambda: participant.quiz_game)()
# Get host_id from message
host_id = data.get('host_id')
print(f'Kick attempt - Game host_id: {game.host_id}, Message host_id: {host_id}')
if not host_id:
print('No host_id in message')
return
if str(game.host_id) != str(host_id):
print(f'Host ID mismatch: {game.host_id} != {host_id}')
return
# Also verify against cookie as backup
cookie_host_id = self.scope.get('cookies', {}).get('host_id')
if not cookie_host_id or str(cookie_host_id) != str(host_id):
print(f'Cookie host_id mismatch: {cookie_host_id} != {host_id}')
return
# Delete the participant
await database_sync_to_async(participant.delete)()
# Notify other players
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'player_left',
'player_name': display_name,
'was_kicked': message_type == 'kick_player',
'participant_id': participant_id
}
)
# Update participants list
await self.update_participants()
# If it's the kicked player's connection, send them home
if participant_id == self.participant_id:
await self.send(text_data=json.dumps({
'type': 'redirect',
'url': '/'
}))
except QuizGameParticipant.DoesNotExist:
pass
elif message_type == 'update_participants':
await self.update_participants()
elif message_type == 'start_game':
host_id = data.get('host_id')
if host_id:
success = await self.start_game(host_id)
if success:
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'game_state_update',
'action': 'start_game',
'redirect_url': f'/play/game/{self.join_code}/question'
}
)
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)
participants = await database_sync_to_async(lambda: list(
game.participants
.values('participant_id', 'display_name')
))()
# Zustand laden
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
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,
{
'type': 'player_joined',
'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}")
self.last_participants = participants
return participants
except QuizGame.DoesNotExist:
return []
async def update_participants(self):
participants = await self.get_participants_list()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'participants_list_update',
'participants': participants
}
)
async def participants_list_update(self, event):
await self.send(text_data=json.dumps({
'type': 'participants_list_update',
'participants': event['participants']
}))
@database_sync_to_async
def start_game(self, host_id):
try:
game = QuizGame.objects.get(join_code=self.join_code)
if str(game.host_id) == str(host_id):
from django.utils import timezone
game.current_state = 'question'
game.question_start_time = timezone.now()
game.save()
return True
except QuizGame.DoesNotExist:
pass
return False
async def game_state_update(self, event):
await self.send(text_data=json.dumps({
'type': 'game_state_update',
'action': event['action'],
'redirect_url': event.get('redirect_url')
}))
async def player_joined(self, event):
await self.send(text_data=json.dumps({
'type': 'player_joined',
'player_name': event['player_name']
}))
async def player_left(self, event):
message = {
'type': 'player_left',
'player_name': event['player_name'],
'was_kicked': event.get('was_kicked', False)
}
if event.get('was_kicked') and event.get('participant_id'):
message['participant_id'] = event['participant_id']
await self.send(text_data=json.dumps(message))

View File

@@ -1,37 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-15 13:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('library', '0015_alter_qivipquiz_difficulty'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='QuizGame',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('join_code', models.CharField(max_length=6, unique=True)),
('host_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='host_user', to=settings.AUTH_USER_MODEL)),
('quiz_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='library.qivipquiz')),
],
),
migrations.CreateModel(
name='QuizGameParticipant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('display_name', models.CharField(max_length=15, verbose_name='Anzeigename')),
('score', models.IntegerField(max_length=10, verbose_name='Punkte')),
('avatar', models.CharField(max_length=200)),
('quiz_game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participant', to='play.quizgame')),
],
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-15 13:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='quizgame',
name='join_code',
field=models.CharField(blank=True, max_length=6, unique=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-15 19:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0002_alter_quizgame_join_code'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='participant_id',
field=models.CharField(default=1, max_length=200, unique=True),
preserve_default=False,
),
migrations.AlterField(
model_name='quizgameparticipant',
name='avatar',
field=models.CharField(blank=True, default='', max_length=200),
),
migrations.AlterField(
model_name='quizgameparticipant',
name='score',
field=models.IntegerField(default=0, verbose_name='Punkte'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 10:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0003_quizgameparticipant_participant_id_and_more'),
]
operations = [
migrations.RemoveField(
model_name='quizgame',
name='host_user',
),
migrations.AddField(
model_name='quizgame',
name='host_id',
field=models.CharField(default=None, max_length=200, unique=True),
preserve_default=False,
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 12:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0004_remove_quizgame_host_user_quizgame_host_id'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='last_heartbeat',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0005_quizgameparticipant_last_heartbeat'),
]
operations = [
migrations.AddField(
model_name='quizgame',
name='current_question_index',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='quizgame',
name='current_state',
field=models.CharField(choices=[('lobby', 'In Lobby'), ('question', 'Frage läuft'), ('scores', 'Punkteübersicht'), ('finished', 'Beendet')], default='lobby', max_length=20),
),
migrations.AddField(
model_name='quizgame',
name='question_start_time',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 18:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0006_quizgame_current_question_index_and_more'),
]
operations = [
migrations.AlterField(
model_name='quizgameparticipant',
name='quiz_game',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='play.quizgame'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 18:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0007_alter_quizgameparticipant_quiz_game'),
]
operations = [
migrations.AddField(
model_name='quizgameparticipant',
name='last_answer',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='quizgameparticipant',
name='last_answer_correct',
field=models.BooleanField(blank=True, null=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 5.1.7 on 2025-04-05 20:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('play', '0008_quizgameparticipant_last_answer_and_more'),
]
operations = [
migrations.CreateModel(
name='QuizAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question_index', models.IntegerField()),
('answer_index', models.IntegerField()),
('is_correct', models.BooleanField()),
('score', models.IntegerField()),
('time_remaining', models.IntegerField()),
('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='play.quizgameparticipant')),
],
options={
'unique_together': {('participant', 'question_index')},
},
),
]

View File

@@ -1,18 +0,0 @@
# 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),
),
]

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

@@ -1,77 +1,3 @@
from django.db import models
from django.contrib.auth.models import User
from library.models import QivipQuiz
from django.http import response
import random, string
# Create your models here.
class QuizGame(models.Model):
GAME_STATES = [
('lobby', 'In Lobby'),
('question', 'Frage läuft'),
('scores', 'Punkteübersicht'),
('finished', 'Beendet')
]
host_id = models.CharField(max_length=200, unique=True)
join_code = models.CharField(max_length=6, unique=True, blank=True)
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
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:
self.join_code = self.generate_unique_code()
super().save(*args, **kwargs)
def generate_unique_code(self):
for i in range(10):
new_code = ''.join(random.choices(string.digits, k=6))
if not QuizGame.objects.filter(join_code=new_code).exists():
return new_code
def generate_unique_id(self):
for i in range(10):
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
return new_id
class QuizGameParticipant(models.Model):
participant_id = models.CharField(max_length=200, unique=True)
display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants")
score = models.IntegerField(verbose_name="Punkte", default=0)
avatar = models.CharField(max_length=200, blank=True, default="")
last_heartbeat = models.DateTimeField(auto_now_add=True)
last_answer = models.IntegerField(null=True, blank=True)
last_answer_correct = models.BooleanField(null=True, blank=True)
def save(self, *args, **kwargs):
if not self.participant_id:
self.participant_id = self.generate_unique_id()
super().save(*args, **kwargs)
def generate_unique_id(self):
for i in range(10):
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
return new_id
class QuizAnswer(models.Model):
participant = models.ForeignKey(QuizGameParticipant, on_delete=models.CASCADE, related_name='answers')
question_index = models.IntegerField()
answer_index = models.IntegerField()
is_correct = models.BooleanField()
score = models.IntegerField()
time_remaining = models.IntegerField()
class Meta:
unique_together = ['participant', 'question_index']
def generate_unique_id(self):
for i in range(10):
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
return new_id

View File

@@ -1,8 +0,0 @@
from django.urls import re_path
from .consumers.game import GameConsumer
from .consumers.lobby import LobbyConsumer
websocket_urlpatterns = [
re_path(r'ws/game/(?P<join_code>\w+)/$', GameConsumer.as_asgi()),
re_path(r'ws/play/lobby/(?P<join_code>\w+)/$', LobbyConsumer.as_asgi()),
]

View File

@@ -4,14 +4,6 @@ from . import views
app_name = 'play'
urlpatterns = [
path('game/new/<int:quiz_id>', views.create_game, name='create_game'),
path('game/<str:join_code>', views.lobby, name='lobby'),
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
path('lobby', views.lobby, name='lobby'),
path('join', views.join_game, name='join_game'),
path('game/<str:join_code>/question', views.question, name='question'),
path('game/<str:join_code>/waiting', views.waiting_room, name='waiting'),
path('game/<str:join_code>/scores', views.scores, name='scores'),
path('game/<str:join_code>/finished', views.finished, name='finished'),
path('select_mode/<str:join_code>', views.select_mode, name='select_mode'),
path('selected_mode/<str:join_code>', views.selected_mode, name='selected_mode'),
]

View File

@@ -1,338 +1,8 @@
from django.utils import timezone
from django.shortcuts import render
from django.shortcuts import render, redirect, get_object_or_404, reverse
from play.models import QuizGame, QuizGameParticipant
from library.models import QivipQuiz
from django.http import HttpResponseRedirect, HttpResponseNotFound
from django.contrib import messages
from django.conf import settings
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 = {
'quiz': quiz,
'quiz_game': quiz_game,
}
mode = request.GET.get('mode','default')
request.session['mode'] = mode
if "host_id" in request.COOKIES:
host_id = request.COOKIES['host_id']
if host_id == quiz_game.host_id:
context['host_id'] = host_id
return render(request, 'play/lobby.html', context=context)
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)
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
if not participant.quiz_game.join_code == join_code: # Teilnehmer dem richtigen Spiel hinzufügen
participant.quiz_game = quiz_game
participant.score = 0 # Reset score when joining new game
participant.save()
except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code)
context['participant'] = participant
return render(request, 'play/lobby.html', context=context)
def 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:
# Prüfe ob der Teilnehmer noch existiert
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
if participant.quiz_game.join_code != 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)
except QuizGameParticipant.DoesNotExist:
# Cookie löschen wenn Teilnehmer nicht mehr existiert
response = HttpResponseRedirect(request.path)
response.delete_cookie('participant_id')
return response
if request.method == 'POST':
display_name = request.POST.get('display_name')
if not display_name:
return render(request, 'play/initialize_participant.html', {
'join_code': join_code,
'error': 'Bitte geben Sie einen Namen ein'
})
participant = QuizGameParticipant()
participant.display_name = display_name
participant.quiz_game = quiz_game
participant.score = 0 # Initialize score
participant.save()
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code}))
response.set_cookie('participant_id', participant.participant_id, max_age=3600)
return response
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
else:
messages.error(request, "Beitritt nicht möglich.")
return render(request, 'play/join_game.html')
def create_game(request, quiz_id):
try:
quiz = QivipQuiz.objects.get(id=quiz_id)
game = QuizGame()
game.quiz_id = quiz
game.host_id = game.generate_unique_id()
game.save()
response = HttpResponseRedirect(reverse('play:select_mode', kwargs={'join_code': game.join_code}))
response.set_cookie('host_id', game.host_id, max_age=3600)
return response
except QivipQuiz.DoesNotExist:
return HttpResponseNotFound("Quiz nicht gefunden")
def lobby(request):
return render(request, 'play/lobby.html')
def join_game(request):
if request.method == 'POST':
join_code = request.POST.get('game_code')
try:
quiz = QuizGame.objects.get(join_code=join_code)
# Redirect to create_participant if no participant cookie exists
if not "participant_id" in request.COOKIES:
return redirect('play:create_participant', join_code=join_code)
return redirect('play:lobby', join_code=join_code)
except QuizGame.DoesNotExist:
messages.error(request, "Dieses Spiel existiert nicht.")
return render(request, 'play/join_game.html')
def finished(request, join_code):
try:
game = QuizGame.objects.get(join_code=join_code)
participant = None
participant_id = request.session.get('participant_id')
if participant_id:
participant = QuizGameParticipant.objects.filter(
quiz_game=game,
participant_id=participant_id
).first()
context = {
'join_code': join_code,
'is_host': str(game.host_id) == str(request.session.get('host_id')),
'participant_id': participant_id if participant else None,
'rating_enabled': settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']
}
return render(request, 'play/game/finished.html', context)
except QuizGame.DoesNotExist:
return redirect('home')
def scores(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state
if quiz_game.current_state != 'scores':
return redirect('play:lobby', join_code=join_code)
# Get participants sorted by score
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
# Check if user is host
is_host = False
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
is_host = True
# Get current question for context
questions = quiz_game.quiz_id.questions.all()
current_question = questions[quiz_game.current_question_index]
question_data = json.loads(current_question.data)
try:
my_participant = request.session.get('my_participant-participant_id')
participant = QuizGameParticipant.objects.get(participant_id=my_participant)
my_participant=participant
except:
participant = QuizGameParticipant.objects.none()
return render(request, 'play/game/score_overview.html', {
'quiz_game': quiz_game,
'participants': participants,
'participant': participant,
'my_participant':my_participant,
'is_host': is_host,
'host_id': quiz_game.host_id if is_host else None,
'is_last_question': quiz_game.current_question_index + 1 >= len(questions),
'question_data': question_data,
'question_index': quiz_game.current_question_index,
'total_questions': len(questions)
})
def finished(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state
if quiz_game.current_state != 'finished':
return redirect('play:lobby', join_code=join_code)
# Get participants sorted by score
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
# Check if user is host
is_host = False
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
is_host = True
# Get participant if exists
participant_id = request.COOKIES.get('participant_id')
participant = None
if participant_id:
participant = QuizGameParticipant.objects.filter(
quiz_game=quiz_game,
participant_id=participant_id
).first()
return render(request, 'play/game/finished.html', {
'quiz_game': quiz_game,
'participants': participants,
'winner': participants.first() if participants.exists() else None,
'join_code': join_code,
'is_host': is_host,
'participant_id': participant_id if participant else None
})
def question(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Verify game state
if quiz_game.current_state != 'question':
return redirect('play:lobby', join_code=join_code)
# Get current question
questions = quiz_game.quiz_id.questions.all()
if quiz_game.current_question_index >= len(questions):
return redirect('play:lobby', join_code=join_code)
current_question = questions[quiz_game.current_question_index]
question_data = json.loads(current_question.data)
# Check if user is host
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
return render(request, 'play/game/question_host.html', {
'quiz_game': quiz_game,
'question_data': question_data,
'current_question':current_question,
'host_id': quiz_game.host_id,
'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
'question_index': quiz_game.current_question_index,
'total_questions': len(questions),
'question_image': current_question.image
})
# Handle participant view
if not 'participant_id' in request.COOKIES:
return redirect('play:create_participant', join_code=join_code)
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code)
request.session['my_participant-participant_id'] = participant.participant_id
return render(request, 'play/game/question_participant.html', {
'quiz_game': quiz_game,
'question_data': question_data,
'current_question':current_question,
'participant': participant,
'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
'question_index': quiz_game.current_question_index,
'total_questions': len(questions),
})
def waiting_room(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
# Check if user is host
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
return redirect('play:question', join_code=join_code)
# Handle participant view
if not 'participant_id' in request.COOKIES:
return redirect('play:create_participant', join_code=join_code)
participant_id = request.COOKIES['participant_id']
try:
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
except QuizGameParticipant.DoesNotExist:
return redirect('play:create_participant', join_code=join_code)
return render(request, 'play/game/wait_for_other_players.html', {
'quiz_game': quiz_game,
'participant': participant
})
def selected_mode(request, join_code):
mode = request.GET.get('mode','default')
request.session['mode'] = mode
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
QuizGameParticipant.objects.filter(quiz_game=quiz_game).delete()
context = {
'quiz': quiz,
'quiz_game': quiz_game,
}
if "host_id" in request.COOKIES:
host_id = request.COOKIES['host_id']
if host_id == quiz_game.host_id:
context['host_id'] = host_id
if mode=="learn":
participant = QuizGameParticipant()
if request.user.is_authenticated:
participant.display_name = request.user.username
else:
participant.display_name = "SIE"
participant.participant_id=host_id
participant.quiz_game = quiz_game
participant.score = 0 # Initialize score
participant.save()
quiz_game.current_state = "question"
quiz_game.question_start_time = timezone.now()
quiz_game.save()
return redirect('play:question',join_code)
return render(request, 'play/lobby.html', context=context)

View File

@@ -51,74 +51,3 @@ a.qp-a-button-small {
@apply p-3 rounded-full bg-indigo-500 hover:bg-indigo-600 text-white focus:scale-105
transition duration-200 font-black shadow-md hover:shadow-lg;
}
ul.messages li {
@apply p-3 rounded-full bg-gray-200 hover:scale-105 transition duration-200 font-black
focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100;
}
ul.messages li.error {
@apply bg-red-200;
}
div.qp-answer-container {
@apply grid grid-cols-2 h-screen;
}
div.qp-answer-container div {
@apply grid place-content-center rounded-xl m-2 place-self-stretch text-white transition duration-300;
@apply nth-1:bg-green-500 hover:nth-1:bg-green-600;
@apply nth-2:bg-red-500 hover:nth-2:bg-red-600;
@apply nth-3:bg-blue-500 hover:nth-3:bg-blue-600;
@apply nth-4:bg-yellow-500 hover:nth-4:bg-yellow-600;
@apply nth-5:bg-purple-500 hover:nth-5:bg-purple-600;
@apply nth-6:bg-black hover:nth-6:bg-slate-800;
}
div.qp-answer-container div p {
@apply font-black text-xl sm:text-2xl md:text-4xl;
}
div.qp-answer-container-host {
@apply grid grid-cols-2 absolute bottom-0 w-screen xl:px-20 xl:pb-10;
}
div.qp-answer-container-host div {
@apply grid place-content-center rounded-xl m-1 place-self-stretch text-white transition duration-300;
@apply nth-1:bg-green-500 hover:nth-1:bg-green-600;
@apply nth-2:bg-red-500 hover:nth-2:bg-red-600;
@apply nth-3:bg-blue-500 hover:nth-3:bg-blue-600;
@apply nth-4:bg-yellow-500 hover:nth-4:bg-yellow-600;
@apply nth-5:bg-purple-500 hover:nth-5:bg-purple-600;
@apply nth-6:bg-black hover:nth-6:bg-slate-800;
}
div.qp-answer-container-host div p {
@apply font-black text-xl p-4;
}
/* Filter Panel Styles */
#filterPanel {
@apply hidden transition-all duration-300 ease-in-out;
}
#filterPanel.show {
@apply block;
}
div.qp-question-container {
@apply text-center text-3xl px-4 py-9 h-screen;
}
div.qp-question-container img {
@apply max-w-[50vw] max-h-[40vh] mx-auto;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,70 +6,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/t-style.css' %}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
<title>qivip</title>
</head>
<body>
{% include 'partials/_nav.html' %}
{% block content%}{% endblock %}
{% include 'partials/_footer.html' %}
<!-- Toast Container -->
<div id="toast-container" class="fixed top-4 right-4 z-50"></div>
<!-- Django Messages to Toast -->
{% if messages %}
<script>
document.addEventListener('DOMContentLoaded', function() {
{% for message in messages %}
// Use the existing toast.create function
toast.create("{{ message }}", "{{ message.tags }}");
{% endfor %}
});
</script>
{% endif %}
<script>
// WebSocket Utility
const wsUtil = {
handleClose: function(e) {
if (e.code === 1000 || e.code === 1001) {
console.log('Socket closed normally');
} else {
console.error('Socket closed unexpectedly', e.code);
}
}
};
// Toast Notification System
const toast = {
create: function(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `mb-4 p-4 rounded-lg shadow-lg transform transition-all duration-300 ease-in-out
${type === 'success' ? 'bg-green-100 border-l-4 border-green-500 text-green-700' :
type === 'error' ? 'bg-red-100 border-l-4 border-red-500 text-red-700' :
type === 'warning' ? 'bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700' :
'bg-blue-100 border-l-4 border-blue-500 text-blue-700'}`;
toast.innerHTML = message;
const container = document.getElementById('toast-container');
container.appendChild(toast);
// Animate in
setTimeout(() => toast.classList.add('translate-x-0', 'opacity-100'), 100);
toast.classList.add('-translate-x-4', 'opacity-0');
// Remove after duration
setTimeout(() => {
toast.classList.add('-translate-x-4', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, duration);
}
};
if (window.location.pathname !== "/library/") {
localStorage.setItem("meine_seite_scroll", 0);}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -2,7 +2,7 @@
{% load static %}
{% block title %}Antwort{% endblock %}
{% block content %}
<link rel="stylesheet" href="{% static 'css/t-style.css' %}">
<link rel="stylesheet" href="{% static 'homepage/t-style.css' %}">
<!-- TODO Bedingung festlegen: Quiztyp unterscheiden % if <Bedingung> % -->
{% if user.is_authenticated %}

View File

@@ -1,5 +1,5 @@
{% load static %}
<link rel="stylesheet" href="{% static 'css/t-style.css' %}">
<link rel="stylesheet" href="{% static 'homepage/t-style.css' %}">
<!doctype html>
<html class="h-full">
<head>

View File

@@ -4,12 +4,12 @@
<div class="grid place-content-center md:h-screen h-150 w-full -z-50 top-0 p-4">
<div class="text-center">
<h2 class="font-bold md:text-8xl text-6xl md:mb-8 text-blue-600">qivip</h2>
<h3 class="mt-4 italic font-extralight sm:text-2xl text-md">Interaktives Lernen neu definiert.</h3>
<h3 class="italic font-extralight sm:text-2xl text-md">Interaktives Lernen neu definiert.</h3>
</div>
<div class="grid sm:grid-cols-3 place-items-stretch text-center mt-8 gap-4 p-4 border-3 bg-blue-100 border-blue-100 rounded-md">
<a class="qp-a-button bg-green-500 text-white" href="{% url 'play:join_game' %}">Teilnehmen</a>
<a class="qp-a-button bg-indigo-500 text-white" href="{% url 'library:overview_quiz' %}">Moderieren</a>
<a class="qp-a-button bg-purple-500 text-white" href="{% url 'library:new_quiz' %}">Erstellen</a>
<a class="qp-a-button bg-green-500 text-white" href="#">Teilnehmen</a>
<a class="qp-a-button bg-indigo-500 text-white" href="#">Moderieren</a>
<a class="qp-a-button bg-purple-500 text-white" href="#">Verwalten</a>
</div>
</div>
{% endblock %}

View File

@@ -1,36 +1,22 @@
{% extends 'base.html' %}
{% block content%}
<div class="flex justify-center items-center mt-12 ">
<div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md">
<div class="grid place-content-center h-120">
<h1 class="text-center m-4 text-3xl lg:text-6xl">Impressum</b></h1>
Katharineum zu Lübeck <br>
Königsstraße 27-31 <br>
23552 Lübeck
{% block content%}
<div>
<div>
<h1>Impressum</h1>
</div>
<div class="flex justify-end">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span>
</div>
<div>
<div>
<p>
Katharineum zu Lübeck
</p>
<p>
Königsstraße 27-31
</p>
<p>
23552 Lübeck
</p>
</div>
</div>
</div>
{% endblock %}
</body>
</html>
</body>
</html>

View File

@@ -1,23 +1,13 @@
{% extends 'base.html' %}
{% block content%}
<div class="flex justify-center items-center mt-12 ">
<div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md">
<div class="grid place-content-center h-120">
<h1 class="text-center m-4 text-3xl lg:text-6xl">Datenschutz-</br>erklärung</h1>
Hier muss die Datenschutzerklärung stehen! <br>
<br>
</div>
<div class="flex justify-end">
<span class="text-[clamp(0.6rem,5vw,1rem)] font-black overflow-x-auto mt-1 ml-0.75 text-blue-600 p-0.75 bg-white">qivip
</span>
</div>
</div>
</div>
{% endblock %}
<div>
<p>
Hallo, ich bin die Datenschutzerklärung!
</p>
</div
{% endblock %}
>
</div>
</body>
</html>

View File

@@ -1,107 +1,32 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4">
<div class="flex flex-wrap space-y-2 items-center justify-between mb-6">
<h1 class="text-2xl font-bold mr-4 ">{{ quiz.name }}</h1>
<div class=" flex flex-wrap space-x-2 ">
<div class="flex flex-wrap">
{% if quiz.user_id == request.user %}
<a href="{% url 'library:edit_quiz' quiz.id %}" class=" text-white px-2 py-2 rounded-md text-sm lg:text-lg hover:scale-110 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7 text-gray-600">
<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=" text-white px-2 py-2 rounded-md text-sm lg:text-lg hover:scale-110 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7 text-gray-600">
<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>
{% endif %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{{ quiz.name }}</h1>
<div class="flex space-x-2">
<a href="{% url 'library:edit_quiz' quiz.id %}" class="bg-green-500 text-white px-4 py-2 rounded-md text-sm lg:text-lg hover:bg-green-600 transition-colors">Quiz bearbeiten</a>
<a href="{% url 'library:delete_quiz' quiz.id %}" class="bg-red-500 text-white px-4 py-2 rounded-md text-sm lg:text-lg hover:bg-red-600 transition-colors">Quiz löschen</a>
</div>
<div class="flex flex-wrap gap-2">
<a href="{% url 'library:copy_quiz' quiz.id %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75" />
</svg>
Quiz kopieren
</a>
{% if quiz.questions.count != 0 %}
<a href="{% url 'play:create_game' quiz.id %}"
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
<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="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>
{% endif %}
</div>
</div>
</div>
{% if quiz.description %}
<p class="text-gray-600 mb-4">{{ quiz.description }}</p>
<p class="text-gray-600 mb-8">{{ quiz.description }}</p>
{% endif %}
<div class="flex justify-end mb-4">
{% if detail == True %}
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=false">
<div class="flex items-center mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
&nbsp; Antworten verstecken
</div>
</a>
{% else %}
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=true">
<div class="flex items-center mr-2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
&nbsp; Antworten anzeigen
</div>
</a>
{% endif %}
</div>
<div class="bg-white rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md">
<div class="border-b border-gray-200 p-4">
<div class="flex justify-between items-center flex-wrap">
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold">Fragen</h2>
<div class="flex space-x-2 flex-wrap">
{% if quiz.user_id == request.user %}
<div class="flex space-x-2">
<a href="{% url 'library:new_question' %}?type=multiple_choice&quiz_id={{ quiz.id }}"
class=" mt-1 bg-blue-100 text-blue-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
class="bg-blue-100 text-blue-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
Multiple Choice
</a>
{% endif %}
{% if quiz.user_id == request.user %}
<a href="{% url 'library:new_question' %}?type=true_false&quiz_id={{ quiz.id }}"
class=" mt-1 bg-purple-100 text-purple-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
class="bg-purple-100 text-purple-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
Wahr/Falsch
</a>
{% endif %}
</div>
</div>
</div>
@@ -112,7 +37,7 @@
{% for question in questions %}
<div class="w-full rounded-xl p-4 hover:bg-gray-50">
{% if quiz.user_id == request.user %} <a class="block flex h-full w-full" href="{% url 'library:edit_question' question.id %}" > {% endif %}
<a class="block flex h-full w-full" href="{% url 'library:edit_question' question.id %}" >
<div class="flex justify-between items-start">
<div class="flex-grow">
@@ -123,54 +48,49 @@
</span>
</div>
<div class="mt-2">
{% if detail %}
{% if question.data.type == 'multiple_choice' %}
<ul class="space-y-1 ">
{% for option in question.data.options %}
<li class="flex items-center text-sm">
{% if option.is_correct %}
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">{{ option.value }}</span>
{% else %}
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
</svg>
<span class="text-gray-600">{{ option.value }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
{% for option in question.data.options %}
{% if option.is_correct %}
<p class="text-sm">
<span class="text-gray-500">Richtige Antwort:</span>
<span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
</p>
<ul class="space-y-1">
{% for option in question.data.options %}
<li class="flex items-center text-sm">
{% if option.is_correct %}
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium text-green-700">{{ option.value }}</span>
{% else %}
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
</svg>
<span class="text-gray-600">{{ option.value }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
{% for option in question.data.options %}
{% if option.is_correct %}
<p class="text-sm">
<span class="text-gray-500">Richtige Antwort:</span>
<span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
</p>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
</div>
</div>
<div class="flex h-full space-x-2 ml-4 items-center">
<!--
<a href="{% url 'library:edit_question' question.id %}"
class="bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200 transition-colors">
Bearbeiten
</a>
-->
<a href=" {% url 'library:delete_question' question.id %}"
class=" text-red-700 px-4 py-1 rounded hover:scale-110 ">
{% if quiz.user_id == request.user %}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7 text-gray-600">
<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>
{% endif %}
</a>
&#x1F5D1
</a>
</div>
</div>
@@ -190,31 +110,5 @@
</div>
{% endif %}
</div>
<div class="flex justify-end">
{% if quiz.base_quiz %}
<p class="text-gray-600 mt-2 ">Bearbeitung:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.user_id }}">{{ quiz.user_id }}</a></span></p>
{% else %}
<p class="text-gray-600 mt-2 ">Autor:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.user_id }}">{{ quiz.user_id }}</a></span></p>
{% endif %}
</div>
<div class="flex justify-end">
{% if quiz.base_quiz %}
<p class="text-gray-600 ">Kopie von: <a href="{% url 'library:detail_quiz' quiz.base_quiz.id %}"><span class="text-blue-600 font-bold">{{ quiz.base_quiz}}</span></a></p>
{% endif %}
</div>
<div class="flex justify-end">
{% if quiz.base_quiz %}
<p class="text-gray-600 ">Autor:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.base_quiz.user_id }}">{{ quiz.base_quiz.user_id }}</a></span></p>
{% endif %}
</div>
<div class="flex justify-end">
{% if quiz.credits %}
<p class="text-gray-600 mb-2">Credits:<span class="text-blue-600 font-bold"> {{ quiz.credits }}</span></p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,35 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<div class="input-group border-blue-600 border-2 rounded-xl shadow-md mt-12">
<form method="post" enctype="multipart/form-data">
<h1>Formular</h1>
<h2>{{ form.name.value }}</h2>
<div class="input-group border-blue-600 border-2 rounded-xl shadow-md">
<form method="post">
{% csrf_token %}
{{ form.as_p }}
{% if form.instance.image %}
<img src="{{ form.instance.image.image.url }}" style="max-width: 100px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_image" id="reset_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="quiz_image">Bild:</label>
<input class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="quiz_image" id="quiz_image">
</div>
<div class="flex justify-center space-x-3">
<a href="{% if object.quiz_id %}{% url 'library:detail_quiz' object.quiz_id.id %}{% else %}{% url 'library:overview_quiz' %}{% endif %}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Abbrechen
</a>
<button type="submit"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Speichern
</button>
</div>
<button type="submit">Speichern</button>
</form>
</div>
{% endblock %}

View File

@@ -1,684 +1,28 @@
{% extends 'base.html' %}
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'swiper/swiper-bundle.min.css' %}">
<style>
.swiper {
width: 100%;
height: 100%;
}
.swiper-slide {
text-align: center;
font-size: 18px;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
}
.custom-outline {
-webkit-text-stroke: 0.2px rgb(0, 0, 0);
}
</style>
{% endblock %}
{% block content %}
<h1>Übersicht</h1>
<div class="flex justify-end">
<a href="{% url 'library:new_quiz' %}" class="text-white bg-blue-500 px-4 py-2 rounded-md mb-12">Neues Quiz erstellen</a>
</div>
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
<button id="filterButton" 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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4 place-items-center">
{% for quiz in quizzes %}
<div class="bg-white w-full max-w-md rounded-lg p-4 shadow-md border-blue-600 border-2 rounded-xl">
<h2 class="text-lg font-bold"><a href="{% url 'library:edit_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
<p class="text-sm text-gray-600 py-12">{{ quiz.description }}</p>
<div>
<div class="flex justify-between items-center gap-2">
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-black">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">&#x270F;</a>
<a href="{% url 'library:delete_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white">&#x1F5D1;</a>
</div>
</div>
</div>
</div>
</div>
<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">
<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" value="{{ form.min_amout_questions.value|default_if_none:'' }}">
</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" value="{{ form.max_amout_questions.value|default_if_none:'' }}">
</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" value="{{ form.user.value|default_if_none:'' }}">
</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>
</svg>
Filtern
</button>
</div>
</form>
</div>
</div>
{% if user.is_authenticated %}
<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="relative w-full h-full inset-0 bg-cover bg-center">
<!-- Hintergrundbild -->
<div class="absolute inset-0 bg-cover bg-center"
style="background-image: url({{ quiz.image.image.url }});">
</div>
</div>
{% else %}
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
{% endif %}
{% if user.is_authenticated %}
{% if quiz.id not in favorite_quiz_ids %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% else %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% endif %}
{% 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">
{% if quiz.rating_count > 0 %}
{% 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>
{% else %}
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
{% endif %}
</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">
{% if quiz.questions.count != 0 %}
<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>
{% else %}
Fürs Spielen ist min. 1 Frage nötig!
{% endif %}
<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_id == 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>
{% endif %}
{% if user.is_authenticated %}
<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">Meine Favoriten</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 favorite_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="relative w-full h-full inset-0 bg-cover bg-center">
<!-- Hintergrundbild -->
<div class="absolute inset-0 bg-cover bg-center"
style="background-image: url({{ quiz.image.image.url }});">
</div>
</div>
{% else %}
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
{% endif %}
{% if user.is_authenticated %}
{% if quiz.favorite == False %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% else %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% endif %}
{% 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">
{% if quiz.rating_count > 0 %}
{% 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>
{% else %}
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
{% endif %}
</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">
{% if quiz.questions.count != 0 %}
<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>
{% else %}
Fürs Spielen ist min. 1 Frage nötig!
{% endif %}
<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_id == 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 favorite_quizzes.paginator.num_pages > 1 %}
<div class="flex justify-center space-x-2 mt-6 mb-8">
{% if favorite_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 {{ favorite_quizzes.number }} von {{ favorite_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>
{% endif %}
<!-- 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="relative w-full h-full inset-0 bg-cover bg-center">
<!-- Hintergrundbild -->
<div class="absolute inset-0 bg-cover bg-center"
style="background-image: url({{ quiz.image.image.url }});">
</div>
</div>
{% else %}
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
{% endif %}
{% if user.is_authenticated %}
{% if quiz.id not in favorite_quiz_ids %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% else %}
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
</svg>
</a>
</div>
{% endif %}
{% 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">
{% if quiz.rating_count > 0 %}
{% 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>
{% else %}
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
{% endif %}
</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">
{% if quiz.questions.count != 0 %}
<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>
{% else %}
Fürs Spielen ist min. 1 Frage nötig!
{% endif %}
<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 %}
<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);
});
window.addEventListener("load", function () {
const scrollPos = localStorage.getItem("meine_seite_scroll");
if (scrollPos !== null) {
window.scrollTo(0, parseInt(scrollPos));
}
});
}
</script>
{% endblock content %}
{% endblock %}

View File

@@ -9,8 +9,9 @@
Zurück zum Quiz
</a>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md">
<form method="post" class="space-y-6" enctype="multipart/form-data">
<div class="bg-white rounded-lg shadow-md p-6">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Question Text -->
@@ -23,19 +24,6 @@
</div>
</div>
{% if image %}
<img src="{{ image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="question_image">Bild:</label>
<input value="image.url" class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="question_image" id="question_image">
</div>
<!-- Options -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Antwortmöglichkeiten</label>
@@ -104,19 +92,6 @@
</button>
</div>
<div class="p-4">
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
<select name="time_per_question" id="time_per_question">
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
</select>
</div>
<div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">

View File

@@ -10,8 +10,8 @@
</a>
</div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md">
<form method="post" class="space-y-6" enctype="multipart/form-data">
<div class="bg-white rounded-lg shadow-md p-6">
<form method="post" class="space-y-6">
{% csrf_token %}
<!-- Question Text -->
@@ -24,18 +24,6 @@
</div>
</div>
{% if image %}
<img src="{{ image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
<label for="reset_image">
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
Bild zurücksetzen
</label>
{% endif %}
<div>
<label class="font-bold" for="question_image">Bild:</label>
<input class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="question_image" id="question_image">
</div>
<!-- True/False Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label>
@@ -55,19 +43,6 @@
</div>
</div>
<div class="p-4">
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
<select name="time_per_question" id="time_per_question">
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
</select>
</div>
<div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}"
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">

View File

@@ -1,121 +1,15 @@
<nav id="main-nav" class="relative bg-blue-600 rounded-lg mt-1 m-2 shadow-md transition-all duration-300">
<!-- Desktop Navigation Bar -->
<div class="flex justify-between items-center h-12 px-4 sm:px-6 overflow-x-auto whitespace-nowrap">
<!-- Logo / Left section -->
<div class="flex items-center flex-shrink-0 md:w-32">
<h2 class="text-xl font-bold text-white hover:scale-110 transition duration-200">
<a href="{% url 'homepage:home' %}">qivip</a>
</h2>
</div>
<!-- Desktop Search (centered on screen) - Hidden on mobile -->
<div class="hidden md:flex justify-center items-center absolute left-1/2 transform -translate-x-1/2 min-w-40">
<form method="get" action="{% url 'library:overview_quiz' %}" class="mr-2 ml-2 flex items-center justify-center w-screen max-w-sm bg-white rounded-full px-4 py-1 shadow-md">
<input type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}"
class="text-sm w-full px-3 py-1 text-black bg-transparent focus:outline-none lg:text-base">
<button class="py-1 text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 md:size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</button>
</form>
</div>
<!-- Desktop Navigation Links - Hidden on mobile -->
<div class="hidden md:flex items-center space-x-4 md:w-40">
<ul class="flex space-x-4 qp-nav-list">
{% if show_search %}
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
{% else%}
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
{% endif %}
{% if user.is_authenticated %}
<li><a href="{% url 'accounts:home' %}">Konto</a></li>
{% else %}
<li><a href="{% url 'accounts:login' %}">Anmelden</a></li>
{% endif %}
</ul>
</div>
<!-- Mobile menu button - Only visible on mobile -->
<div class="flex md:hidden items-center">
<button id="mobile-menu-button" class="text-white focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<nav class="flex justify-between bg-blue-600 h-12 px-4 sm:px-6 lg:px-8 rounded-lg m-2 shadow-md">
<div class="flex items-center">
<h2 class="text-xl font-bold text-white hover:scale-110 transition duration-200"><a href="{% url 'homepage:home' %}">qivip</a></h2>
</div>
<!-- Mobile Navigation Menu - Hidden by default - FULLWIDTH DROPDOWN -->
<div id="mobile-menu" class="hidden absolute w-full z-50 md:hidden opacity-0 transform -translate-y-2 transition-all duration-300">
<div class="bg-blue-500 pt-3 pb-4 rounded-b-lg shadow-inner">
<!-- Search in mobile menu -->
<form method="get" action="{% url 'library:overview_quiz' %}" class="px-4 mb-4">
<div class="flex items-center justify-center bg-white rounded-full px-4 py-2 shadow-md">
<input type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}"
class="w-full text-sm px-2 text-black bg-transparent focus:outline-none">
<button class="text-blue-600">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="size-5">
<path stroke-linecap="round" stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</button>
</div>
</form>
<!-- Mobile Navigation Links -->
<div class="space-y-2 px-4">
<a href="{% url 'library:overview_quiz' %}" class="block py-2 text-white font-medium rounded-lg hover:bg-blue-600 transition duration-200">
Bibliothek
</a>
{% if user.is_authenticated %}
<a href="{% url 'accounts:home' %}" class="block py-2 text-white font-medium rounded-lg hover:bg-blue-600 transition duration-200">
Konto
</a>
{% else %}
<a href="{% url 'accounts:login' %}" class="block py-2 text-white font-medium rounded-lg hover:bg-blue-600 transition duration-200">
Anmelden
</a>
{% endif %}
</div>
</div>
<div class="flex items-center">
<ul class="flex space-x-4 qp-nav-list">
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
{% if user.is_authenticated %}
<li><a href="{% url 'accounts:home' %}">Konto</a></li>
{% else %}
<li><a href="{% url 'accounts:login' %}">Anmelden</a></li>
{% endif %}
</ul>
</div>
</nav>
<!-- Mobile Menu Script -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const mainNav = document.getElementById('main-nav');
mobileMenuButton.addEventListener('click', function() {
// Toggle menu visibility
if (mobileMenu.classList.contains('hidden')) {
// Show menu
mobileMenu.classList.remove('hidden');
// Entferne unten abgerundete Ecken der Navbar wenn Menü offen
mainNav.classList.remove('rounded-lg');
mainNav.classList.add('rounded-t-lg');
// Animation einfahren
setTimeout(() => {
mobileMenu.classList.remove('opacity-0', 'transform', '-translate-y-2');
mobileMenu.classList.add('opacity-100');
}, 50);
} else {
// Ausfahren Animation
mobileMenu.classList.add('opacity-0', 'transform', '-translate-y-2');
mobileMenu.classList.remove('opacity-100');
// Nach der Animation verstecken
setTimeout(() => {
mobileMenu.classList.add('hidden');
// Stelle die abgerundeten Ecken wieder her
mainNav.classList.remove('rounded-t-lg');
mainNav.classList.add('rounded-lg');
}, 300);
}
});
});
</script>

View File

@@ -1,89 +0,0 @@
{% load static %}
<script>
// Gemeinsame Funktionen für alle Spielseiten
// Toast-Benachrichtigungen und WebSocket-Funktionen werden im globalen Scope definiert
window.toast = {
create(message, type = 'info') {
const toastElement = document.createElement('div');
toastElement.className = `toast toast-${type}`;
toastElement.textContent = message;
document.body.appendChild(toastElement);
// Animation
setTimeout(() => {
toastElement.classList.add('show');
setTimeout(() => {
toastElement.classList.remove('show');
setTimeout(() => toastElement.remove(), 300);
}, 3000);
}, 100);
}
};
// WebSocket-Hilfsfunktionen
window.wsUtil = {
handleClose(e) {
if (e.code !== 1000 && e.code !== 1001) {
console.error('WebSocket closed unexpectedly', e.code);
}
}
};
// WebSocket-Initialisierung
window.initializeGameSocket = function(gameCode) {
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
const gameSocket = new WebSocket(
`${wsScheme}://${window.location.host}/ws/game/${gameCode}/`
);
gameSocket.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'player_left') {
const message = data.was_kicked
? `${data.player_name} wurde wegen Inaktivität entfernt`
: `${data.player_name} hat das Spiel verlassen`;
toast.create(message, data.was_kicked ? 'error' : 'warning');
}
};
gameSocket.onclose = (e) => wsUtil.handleClose(e);
return gameSocket;
}
</script>
<style>
.toast {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
border-radius: 4px;
color: white;
opacity: 0;
transition: opacity 0.3s ease-in-out;
z-index: 9999;
max-width: 300px;
}
.toast.show {
opacity: 1;
}
.toast-info {
background-color: #3498db;
}
.toast-success {
background-color: #2ecc71;
}
.toast-warning {
background-color: #f1c40f;
}
.toast-error {
background-color: #e74c3c;
}
</style>

View File

@@ -1,205 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container mx-auto px-4">
<div class="max-w-2xl mx-auto">
<div class="bg-white rounded-lg shadow-md p-8 mb-6">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-blue-600 mb-4">Quiz beendet!</h1>
<p class="text-xl text-gray-600">Vielen Dank fürs Spielen!</p>
</div>
{% if not is_host %}
{% if rating_enabled %}
<div class="bg-blue-50 rounded-lg p-6 mb-8">
<h2 class="text-2xl font-bold text-blue-800 mb-4 text-center">Wie hat dir das Quiz gefallen?</h2>
<div class="stars flex justify-center space-x-4 mb-4">
<i class="fas fa-star text-3xl transition-all duration-200 hover:scale-110" data-rating="1"></i>
<i class="fas fa-star text-3xl transition-all duration-200 hover:scale-110" data-rating="2"></i>
<i class="fas fa-star text-3xl transition-all duration-200 hover:scale-110" data-rating="3"></i>
<i class="fas fa-star text-3xl transition-all duration-200 hover:scale-110" data-rating="4"></i>
<i class="fas fa-star text-3xl transition-all duration-200 hover:scale-110" data-rating="5"></i>
</div>
<p id="rating-message" class="text-center text-blue-600 font-medium"></p>
</div>
{% else %}
<p class="text-center text-blue-600 font-medium mb-8">Bewertungen sind deaktiviert</p>
{% endif %}
{% endif %}
<div class="text-center">
<a href="{% url 'homepage:home' %}"
class="inline-block px-8 py-3 text-lg font-semibold text-white bg-blue-600 rounded-full hover:bg-blue-700 transform transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Zurück zur Startseite
</a>
</div>
</div>
</div>
</div>
{% if not is_host %}
{% include 'play/game/common_scripts.html' %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
let gameSocket = null;
let reconnectAttempts = 0;
let pingInterval = null;
const maxReconnectAttempts = 5;
const pingDelay = 30000; // 30 seconds
function startPingInterval() {
if (pingInterval) {
clearInterval(pingInterval);
}
pingInterval = setInterval(() => {
if (gameSocket && gameSocket.readyState === WebSocket.OPEN) {
gameSocket.send(JSON.stringify({ type: 'ping' }));
}
}, pingDelay);
}
function stopPingInterval() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
function connectWebSocket() {
if (gameSocket && (gameSocket.readyState === WebSocket.CONNECTING || gameSocket.readyState === WebSocket.OPEN)) {
return gameSocket; // Already connected or connecting
}
if (reconnectAttempts >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
document.getElementById('rating-message').textContent = 'Verbindungsfehler. Bitte Seite neu laden.';
return null;
}
try {
gameSocket = new WebSocket(
`${wsScheme}://${window.location.host}/ws/game/{{ join_code }}/`
);
gameSocket.onopen = function(e) {
console.log('Game socket connected');
reconnectAttempts = 0;
startPingInterval();
document.getElementById('rating-message').textContent = '';
};
gameSocket.onclose = function(e) {
wsUtil.handleClose(e);
stopPingInterval();
gameSocket = null;
reconnectAttempts++;
if (reconnectAttempts < maxReconnectAttempts) {
document.getElementById('rating-message').textContent = 'Verbindung wird wiederhergestellt...';
setTimeout(connectWebSocket, 1000 * Math.min(reconnectAttempts, 3));
}
};
gameSocket.onerror = function(e) {
console.error('Game socket error:', e);
document.getElementById('rating-message').textContent = 'Verbindungsfehler aufgetreten';
};
return gameSocket;
} catch (error) {
console.error('Error creating WebSocket:', error);
document.getElementById('rating-message').textContent = 'Verbindungsfehler aufgetreten';
return null;
}
}
const stars = document.querySelectorAll('.stars i');
let rated = false;
function setRating(rating) {
stars.forEach((star, index) => {
star.style.color = index < rating ? '#ffc107' : '#e4e5e9';
});
}
stars.forEach(star => {
star.addEventListener('mouseover', function() {
if (!rated) {
setRating(this.dataset.rating);
}
});
star.addEventListener('mouseout', function() {
if (!rated) {
setRating(0);
}
});
star.addEventListener('click', function() {
if (!rated) {
const rating = parseInt(this.dataset.rating);
rated = true;
setRating(rating);
if (!gameSocket || gameSocket.readyState !== WebSocket.OPEN) {
console.error('WebSocket is not connected');
document.getElementById('rating-message').textContent = 'Verbindung wird hergestellt...';
gameSocket = connectWebSocket();
if (gameSocket) {
const retryRating = () => {
if (gameSocket.readyState === WebSocket.OPEN) {
star.click();
} else {
setTimeout(retryRating, 500);
}
};
setTimeout(retryRating, 1000);
}
return;
}
try {
gameSocket.send(JSON.stringify({
'type': 'submit_rating',
'participant_id': '{{ participant_id }}',
'rating': rating
}));
document.getElementById('rating-message').textContent = 'Danke für deine Bewertung!';
stars.forEach(s => s.style.cursor = 'default');
} catch (error) {
console.error('Error sending rating:', error);
document.getElementById('rating-message').textContent = 'Fehler beim Senden der Bewertung';
rated = false;
}
}
});
});
// Initial connection
gameSocket = connectWebSocket();
});
</script>
<style>
.stars i {
cursor: pointer;
color: #e4e5e9;
transition: all 0.2s ease-in-out;
}
.stars i:hover ~ i {
color: #e4e5e9;
}
.stars:hover i {
color: #ffc107;
}
.stars i:hover {
transform: scale(1.2);
color: #ffc107;
}
</style>
{% endif %}
{% endblock %}

View File

@@ -1,207 +0,0 @@
{% extends 'base.html' %}
{% block content %}
{% if request.session.mode == "learn" %}
<div class="container mx-auto px-4">
<div class="flex justify-end mb-4">
<button onclick="leaveGame()" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
Spiel verlassen
</button>
</div>{% endif %}
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<p class="text-gray-700 font-bold text-xl mb-4">Frage {{ question_index|add:1 }} von {{ total_questions }}</p>
<h1 class="text-3xl font-bold mb-6">{{ question_data.question }}</h1>
{% if question_image %}
<div class="flex justify-center">
<img class="m-4" src="{{ question_image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
</div>
{% endif %}
{% if request.session.mode == "learn" %}
<div class="grid grid-cols-1 gap-4 mb-6">
{% else %}
<div class="grid grid-cols-2 gap-4 mb-6">
{% endif %}
{% if request.session.mode != "learn" %}
<div class="p-4 bg-blue-50 rounded-lg">
<p class="text-blue-600 text-xl mb-2">Verbleibende Zeit </p>
<p id="remaining-time" class="text-6xl font-bold text-blue-700">30</p>
</div>
{% endif %}
<div class="p-4 bg-blue-50 rounded-lg">
<p class="text-blue-600 text-xl mb-2">Antworten</p>
<p id="answer-count" class="text-6xl font-bold text-blue-700">0/0</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4" id="options-container">
{% for option in question_data.options %}
<button
class="p-4 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option"
data-index="{{ forloop.counter0 }}"
{% if request.session.mode == "learn" %}
onclick="submitAnswer({{ forloop.counter0 }});" {% endif %}>
<p class="text-lg">{{ option.value }}</p>
</button>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% include 'play/game/common_scripts.html' %}
<script>
const joinCode = '{{ quiz_game.join_code }}';
const hostId = '{{ host_id }}';
const questionStartTime = {{ start_time }};
const questionDuration = '{{ current_question.time_per_question }}'*1000; //Zeit pro Frage (indviduell eingestellt)
const gameSocket = initializeGameSocket(joinCode);
let answeredParticipants = 0;
let totalParticipants = 0;
const optionCounts = {};
gameSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'participant_answer') {
answeredParticipants++;
updateAnswerCount();
updateOptionCount(data.answer);
} else if (data.type === 'participant_list_update') {
totalParticipants = data.participants.length;
updateAnswerCount();
} else if (data.type === 'game_state_update' &&
data.action === 'show_scores' &&
data.redirect_url) {
window.location.href = data.redirect_url;
}
};
gameSocket.onclose = function(e) {
wsUtil.handleClose(e);
};
function updateAnswerCount() {
const answerCountElement = document.getElementById('answer-count');
if (answerCountElement) {
answerCountElement.textContent = `${answeredParticipants}/${totalParticipants}`;
}
// If everyone has answered, advance to scores
if (answeredParticipants === totalParticipants && totalParticipants > 0) {
advanceToScores();
}
}
function updateOptionCount(optionIndex) {
optionCounts[optionIndex] = (optionCounts[optionIndex] || 0) + 1;
const optionElement = document.getElementById(`option-count-${optionIndex}`);
if (optionElement) {
optionElement.textContent = `${optionCounts[optionIndex]} Antworten`;
}
}
function advanceToScores() {
// Show correct answers and answer counts
var correctOptions = document.querySelectorAll('[data-correct="True"]');
for (var i = 0; i < correctOptions.length; i++) {
correctOptions[i].classList.add('border-2', 'border-blue-500');
}
// Show answer counts
var countElements = document.querySelectorAll('[id^="option-count-"]');
for (var j = 0; j < countElements.length; j++) {
countElements[j].classList.remove('hidden');
}
// Wait a moment to show the correct answer and counts
setTimeout(function() {
gameSocket.send(JSON.stringify({
'type': 'advance_to_scores',
'host_id': hostId
}));
}, 2000);
}
// Timer
{% if request.session.mode != "learn" %}
function updateTimer() {
const now = Date.now();
const elapsed = now - questionStartTime;
const remaining = Math.max(0, Math.ceil((questionDuration - elapsed) / 1000));
document.getElementById('remaining-time').textContent = remaining;
if (remaining === 0) {
advanceToScores();
} else {
requestAnimationFrame(updateTimer);
}
}
{% endif %}
// Request initial participants list
gameSocket.onopen = function(e) {
gameSocket.send(JSON.stringify({
'type': 'update_participants'
}));
updateTimer();
};
let hasAnswered = false;
const participantId = '{{ host_id }}'; // HIER IST DER HOST DER SPIELER
function leaveGame() {
if (confirm('Möchtest du das Spiel wirklich verlassen?')) {
gameSocket.send(JSON.stringify({
type: 'leave_game',
participant_id: participantId
}));
window.location.href = '/';
gameSocket.close();
}
}
function submitAnswer(optionIndex) {
if (hasAnswered) return;
hasAnswered = true;
const now = Date.now();
const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
// Disable all options and highlight selected
const options = document.getElementsByClassName('answer-option');
for (let option of options) {
option.disabled = true;
option.classList.remove('hover:bg-blue-100');
if (parseInt(option.dataset.index) === optionIndex) {
option.classList.remove('bg-gray-100');
option.classList.add('bg-blue-600', 'text-white');
} else {
option.classList.add('opacity-50');
}
}
// Send answer to server
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer: optionIndex,
time_remaining: timeRemaining
}));
}
</script>
{% endblock %}

View File

@@ -1,115 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4">
<div class="flex justify-end mb-4">
<button onclick="leaveGame()" class="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded">
Spiel verlassen
</button>
</div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<p class="text-gray-700 font-bold text-xl mb-4">Frage {{ question_index|add:1 }} von {{ total_questions }}</p>
<h1 class="text-3xl font-bold mb-6">{{ question_data.question }}</h1>
<div class="p-4 bg-blue-50 rounded-lg mb-6">
<p class="text-blue-600 text-xl mb-2">Verbleibende Zeit</p>
<p id="remaining-time" class="text-6xl font-bold text-blue-700">30</p>
</div>
<div class="grid grid-cols-2 gap-4" id="options-container">
{% for option in question_data.options %}
<button
class="p-4 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option"
data-index="{{ forloop.counter0 }}"
onclick="submitAnswer({{ forloop.counter0 }});">
<p class="text-lg">{{ option.value }}</p>
</button>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% include 'play/game/common_scripts.html' %}
<script>
let hasAnswered = false;
const joinCode = '{{ quiz_game.join_code }}';
const participantId = '{{ participant.participant_id }}';
const questionStartTime = {{ start_time }};
const questionDuration = '{{ current_question.time_per_question }}'*1000; //Zeit pro Frage (indviduell eingestellt)
const gameSocket = initializeGameSocket(joinCode);
gameSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'redirect' && data.url) {
window.location.href = data.url;
} else if (data.type === 'game_state_update' &&
data.action === 'show_scores' &&
data.redirect_url) {
window.location.href = data.redirect_url;
}
};
gameSocket.onclose = function(e) {
wsUtil.handleClose(e);
};
function submitAnswer(optionIndex) {
if (hasAnswered) return;
hasAnswered = true;
const now = Date.now();
const timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
// Disable all options and highlight selected
const options = document.getElementsByClassName('answer-option');
for (let option of options) {
option.disabled = true;
option.classList.remove('hover:bg-blue-100');
if (parseInt(option.dataset.index) === optionIndex) {
option.classList.remove('bg-gray-100');
option.classList.add('bg-blue-600', 'text-white');
} else {
option.classList.add('opacity-50');
}
}
// Send answer to server
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer: optionIndex,
time_remaining: timeRemaining
}));
}
// Timer
function updateTimer() {
const now = Date.now();
const elapsed = now - questionStartTime;
const remaining = Math.max(0, Math.ceil((questionDuration - elapsed) / 1000));
document.getElementById('remaining-time').textContent = remaining;
if (remaining === 0 && !hasAnswered) {
// Auto-submit timeout answer
submitAnswer(-1);
} else if (remaining > 0) {
requestAnimationFrame(updateTimer);
}
}
function leaveGame() {
if (confirm('Möchtest du das Spiel wirklich verlassen?')) {
gameSocket.send(JSON.stringify({
type: 'leave_game',
participant_id: participantId
}));
}
}
// Start timer when page loads
updateTimer();
</script>
{% endblock %}

View File

@@ -1,196 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4">
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<h1 class="text-3xl font-bold mb-6">Ergebnisse</h1>
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Aktuelle Frage</h2>
<p class="text-lg mb-2">{{ question_data.question }}</p>
<div class="grid grid-cols-2 gap-4">
{% for option in question_data.options %}
<div class="p-4 rounded-lg {% if option.is_correct %}bg-green-100 border-2 border-green-500 {% else %} bg-gray-100 {% endif %}">
<p class="text-lg">{{ option.value }}</p>
<p class="text-gray-600" id="option-count-{{ forloop.counter0 }}">0 Antworten</p>
</div>
{% endfor %}
</div>
</div>
{% if request.session.mode != "learn" %}
<div class="mb-6">
<h2 class="text-xl font-bold mb-4">Punktestand</h2>
<div id="scoreboard" class="space-y-2">
{% for participant in participants %}
{% if participant == my_participant or is_host %}
<div class="p-4 bg-gray-100 rounded-lg flex justify-between items-center border {% if forloop.counter <= 3 %}border-yellow-500{% else %}border-transparent{% endif %}">
<div>
<p class="text-lg font-bold">
{% if forloop.counter == 1 %}
🥇
{% elif forloop.counter == 2 %}
🥈
{% elif forloop.counter == 3 %}
🥉
{% endif %}
{{ participant.display_name }}
</p>
<p class="text-gray-600">{{ participant.score }} Punkte</p>
</div>
{% if participant.last_answer_correct %}
<span class="text-green-500"></span>
{% elif participant.last_answer_correct == False %}
<span class="text-red-500"></span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% if is_host %}
{% if is_last_question %}
<button id="finish-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200">
Quiz beenden
</button>
{% else %}
<button id="next-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200">
Nächste Frage
</button>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if is_last_question %}
<script>
// Hilfsfunktion für Pausen
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
document.addEventListener('DOMContentLoaded', async function() {
const items = document.querySelectorAll('#scoreboard div');
const top3 = Array.from(items).slice(0, 3); // Nur die ersten 3 animieren
// Sofort Startzustände für Top3 setzen, die anderen sofort sichtbar
items.forEach((el, index) => {
if (index >= 3) {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
} else {
el.style.opacity = '0';
el.style.transform = 'translateY(20px)';
}
});
// Warten bevor Animation startet
await sleep(1500); // <<< hier die 5 Sekunden Pause
// Transition-Eigenschaft setzen nach Render-Zyklus
requestAnimationFrame(() => {
top3.forEach(el => {
el.style.transition = 'all 0.8s ease';
});
// Animation für Top 3 starten von Platz 3 zu 1
top3.reverse().forEach((el, index) => {
setTimeout(() => {
el.style.opacity = '1';
el.style.transform = 'translateY(0)';
if (index === top3.length - 1) {
triggerConfetti();
}
}, index * 1500); // Zeitversetzt aufdecken
});
});
});
function triggerConfetti() {
const end = Date.now() + 15 * 1000;
(function frame() {
confetti({ particleCount: 2, angle: 60, spread: 55, origin: { x: 0 } });
confetti({ particleCount: 2, angle: 120, spread: 55, origin: { x: 1 } });
if (Date.now() < end) requestAnimationFrame(frame);
})();
}
</script>
{% endif %}
{% include 'play/game/common_scripts.html' %}
<script>
const joinCode = '{{ quiz_game.join_code }}';
const hostId = '{{ host_id }}';
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
const gameSocket = new WebSocket(
`${wsScheme}://${window.location.host}/ws/game/${joinCode}/`
);
gameSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'game_state_update' &&
data.redirect_url &&
(data.action === 'next_question' || data.action === 'finish_game')) {
window.location.href = data.redirect_url;
} else if (data.type === 'answer_stats') {
updateAnswerStats(data.stats);
}
};
gameSocket.onclose = function(e) {
// Normal closure (code 1000) oder Navigation zu einer anderen Seite (code 1001)
if (e.code === 1000 || e.code === 1001) {
console.log('Game socket closed normally');
} else {
console.error('Game socket closed unexpectedly', e.code);
}
};
{% if is_host %}
// Add click handlers for host buttons
{% if is_last_question %}
var finishButton = document.getElementById('finish-button');
if (finishButton) {
finishButton.addEventListener('click', function() {
gameSocket.send(JSON.stringify({
'type': 'finish_game',
'host_id': hostId
}));
});
}
{% else %}
var nextButton = document.getElementById('next-button');
if (nextButton) {
nextButton.addEventListener('click', function() {
gameSocket.send(JSON.stringify({
'type': 'next_question',
'host_id': hostId
}));
});
}
{% endif %}
{% endif %}
function updateAnswerStats(stats) {
for (var optionIndex in stats) {
if (stats.hasOwnProperty(optionIndex)) {
var element = document.getElementById('option-count-' + optionIndex);
if (element) {
element.textContent = stats[optionIndex] + ' Antwort(en)';
}
}
}
}
// Request answer stats when page loads
gameSocket.onopen = function(e) {
gameSocket.send(JSON.stringify({
'type': 'get_answer_stats'
}));
};
</script>
{% endblock %}

View File

@@ -1,53 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4">
<div class="bg-white rounded-lg shadow-md p-6 mb-6 text-center">
<h1 class="text-3xl font-bold mb-6">Warte auf andere Spieler...</h1>
<div class="p-4 bg-blue-50 rounded-lg mb-6 inline-block">
<p class="text-blue-600 text-xl mb-2">Die nächste Frage beginnt in</p>
<p id="countdown" class="text-6xl font-bold text-blue-700">5</p>
</div>
<div class="animate-pulse">
<p class="text-gray-600">Bitte warte, bis alle Spieler bereit sind</p>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% include 'play/game/common_scripts.html' %}
<script>
const joinCode = '{{ quiz_game.join_code }}';
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
const gameSocket = new WebSocket(
wsScheme + '://' + window.location.host + '/ws/game/' + joinCode + '/'
);
let countdown = 5;
const countdownElement = document.getElementById('countdown');
gameSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'game_state_update' && data.action === 'start_question' && data.redirect_url) {
window.location.href = data.redirect_url;
}
};
gameSocket.onclose = function(e) {
wsUtil.handleClose(e);
};
function updateCountdown() {
countdownElement.textContent = countdown;
if (countdown > 0) {
countdown--;
setTimeout(updateCountdown, 1000);
}
}
updateCountdown();
</script>
{% endblock %}

View File

@@ -1,63 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
<h1 class="text-2xl font-bold mb-6 text-center text-blue-600">Spieler einrichten</h1>
<div class="text-center mb-6">
<div class="inline-block bg-blue-100 rounded-lg px-4 py-2">
<span class="text-sm text-blue-800">Spiel-Code:</span>
<span class="font-mono font-bold text-blue-900">{{ join_code }}</span>
</div>
</div>
<form method="post" class="space-y-4">
{% csrf_token %}
<input type="hidden" name="join_code" value="{{join_code}}">
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<p class="text-sm text-gray-600 mb-2">Wähle einen Anzeigenamen für das Spiel:</p>
<input type="text"
name="display_name"
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">
<p class="text-xs text-gray-500 mt-2">Maximal 20 Zeichen</p>
</div>
<div class="flex justify-center space-x-4">
<a href="/"
class="px-6 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors duration-200">
Abbrechen
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200">
Spiel beitreten
</button>
</div>
</form>
{% if error %}
<div class="mt-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700">
<p class="font-medium">Fehler</p>
<p class="text-sm">{{ error }}</p>
</div>
{% endif %}
</div>
</div>
<style>
input:focus {
outline: none;
}
.container {
min-height: calc(100vh - 4rem);
display: flex;
align-items: center;
}
</style>
{% endblock %}

View File

@@ -1,13 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<div class="sm:grid sm:place-items-center sm:min-h-[calc(100vh-5rem)] flex justify-center py-6 m-2">
<div class="grid place-items-center min-h-[calc(100vh-5rem)] py-6 m-2">
<div class="max-w-md w-full p-8 rounded-2xl border-2 border-blue-400 bg-white">
<h1 class="text-2xl text-center font-bold mb-4">Spiel beitreten</h1>
<form action="{% url 'play:join_game' %}" method="post" class="flex flex-col gap-4">
{% csrf_token %}
<div class="flex gap-3 items-center justify-center">
<input type="text" name="game_code" placeholder="123456" maxlength="6" class="w-32 p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100 text-center tracking-wider" autofocus>
<input type="text" name="game_code" placeholder="XXX-XXX" maxlength="7" pattern="[A-Za-z0-9]{3}-[A-Za-z0-9]{3}" class="w-32 p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100 text-center uppercase tracking-wider" oninput="let v = this.value.replace(/[^A-Za-z0-9]/g, '').slice(0,6).toUpperCase(); this.value = v.length > 3 ? v.slice(0,3) + '-' + v.slice(3) : v;" autofocus>
<button type="submit" class="h-12 px-6 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 active:scale-95 group" disabled>
<span class="hidden sm:block">Beitreten</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 transition-transform group-hover:translate-x-1">
@@ -15,20 +15,13 @@
</svg>
</button>
</div>
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
</form>
<script>
const input = document.querySelector('input[name="game_code"]');
const button = document.querySelector('button[type="submit"]');
input.addEventListener('input', function(e) {
button.disabled = !this.value.match(/^[0-9]{6}$/);
button.disabled = !this.value.match(/^[A-Z0-9]{3}-[A-Z0-9]{3}$/);
});
input.addEventListener('keypress', function(e) {

View File

@@ -1,374 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-lg p-6">
<!-- Quiz Info -->
<div class="text-center mb-8">
<h3 class="text-xl text-gray-600 mb-2">{{ quiz.name }}</h3>
{% if request.session.mode != "learn" %}
<div class="bg-blue-100 rounded-lg p-4 mb-4">
<h1 class="text-4xl font-bold text-blue-800">{{ quiz_game.join_code }}</h1>
<p class="text-sm text-blue-600 mt-1">Spiel-Code</p>
</div>
</div>
{% endif %}
<!-- Participant Info -->
{% if participant %}
<div class="mb-6 text-center">
<div class="inline-flex items-center bg-green-100 px-4 py-2 rounded-full">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
<span>Angemeldet als <b>{{ participant.display_name }}</b></span>
</div>
</div>
{% endif %}
<!-- Participants List -->
<div class="mb-6">
<h3 class="font-bold text-lg mb-3 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Teilnehmer
</h3>
<ul id="participants-list" class="space-y-2 max-h-60">
<li class="text-gray-500 p-3 bg-gray-50 rounded-lg text-center">Warte auf Teilnehmer...</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="space-y-3">
{% if host_id %}
<button id="start-button" class="w-full p-4 rounded-lg bg-blue-600 text-white font-bold hover:bg-blue-700 transform hover:scale-105 transition duration-200 shadow-md flex items-center justify-center" type="button">
<svg class="w-5 h-5 mr-2" 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>
Spiel starten
</button>
{% endif %}
{% if participant %}
<button id="leave-button" class="w-full p-4 rounded-lg bg-red-100 text-red-700 font-bold hover:bg-red-200 transition duration-200 flex items-center justify-center" type="button">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Spiel verlassen
</button>
{% endif %}
</div>
<div class="container mx-auto px-4">
<h1>Bundeslaender Deutschland Quiz</h1>
<div id="player-list">
<ul>
<li>Spieler 1</li>
<li>Spieler 2</li>
<li>Spieler 3</li>
</ul>
</div>
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit">Starten</button>
</div>
{% endblock %}
{% block extra_js %}
<script>
const joinCode = '{{ quiz_game.join_code }}';
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
let lobbySocket = null;
let reconnectAttempts = 0;
let pingInterval = null;
const maxReconnectAttempts = 5;
const pingDelay = 30000; // 30 seconds
const heartbeatDelay = 10000; // 10 seconds
let heartbeatInterval = null;
function startPingInterval() {
stopPingInterval();
pingInterval = setInterval(() => {
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
lobbySocket.send(JSON.stringify({ type: 'ping' }));
}
}, pingDelay);
}
function stopPingInterval() {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
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() {
if (lobbySocket && (lobbySocket.readyState === WebSocket.CONNECTING || lobbySocket.readyState === WebSocket.OPEN)) {
return lobbySocket; // Already connected or connecting
}
if (reconnectAttempts >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return null;
}
try {
lobbySocket = new WebSocket(
wsScheme + '://' + window.location.host + '/ws/play/lobby/' + joinCode + '/'
);
lobbySocket.onopen = function(e) {
console.log('Lobby socket connected');
reconnectAttempts = 0;
startPingInterval();
{% if participant %}
startHeartbeat();
{% endif %}
// Request initial participants list
lobbySocket.send(JSON.stringify({
type: 'update_participants'
}));
};
lobbySocket.onclose = function(e) {
wsUtil.handleClose(e);
stopPingInterval();
stopHeartbeat();
lobbySocket = null;
reconnectAttempts++;
if (reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Exponential backoff, max 10s
setTimeout(connectWebSocket, delay);
}
};
lobbySocket.onerror = function(e) {
console.error('Lobby socket error:', e);
};
lobbySocket.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'participants_list_update') {
updateParticipantsList(data.participants);
} else if (data.type === 'game_state_update' &&
data.action === 'start_game' &&
data.redirect_url) {
window.location.href = data.redirect_url;
} else if (data.type === 'pong') {
console.log('Received pong from server');
} else if (data.type === 'redirect' && data.url) {
window.location.href = data.url;
} else if (data.type === 'player_joined' && '{{ host_id }}') {
toast.create(`${data.player_name} ist beigetreten`, 'success');
} else if (data.type === 'player_left') {
if (data.was_kicked) {
// Show kick message
toast.create(`${data.player_name} wurde vom Host entfernt`, 'error');
// If this client was kicked, redirect to home
if (data.participant_id === '{{ participant.participant_id }}') {
window.location.href = '/';
}
} else {
toast.create(`${data.player_name} hat das Spiel verlassen`, 'info');
}
}
};
return lobbySocket;
} catch (error) {
console.error('Error creating WebSocket:', error);
return null;
}
}
// Initial connection
lobbySocket = connectWebSocket();
function leaveGame() {
if (confirm('Möchtest du das Spiel wirklich verlassen?')) {
lobbySocket.send(JSON.stringify({
type: 'leave_game',
participant_id: '{{ participant.participant_id }}'
}));
}
}
function kickPlayer(participantId) {
const button = event.currentTarget;
if (!button.disabled && confirm('Möchtest du diesen Spieler wirklich aus dem Spiel entfernen?')) {
console.log('Attempting to kick player:', participantId);
console.log('Host ID:', '{{ quiz_game.host_id }}');
console.log('Current host cookie:', document.cookie.split('; ').find(row => row.startsWith('host_id=')));
button.disabled = true;
button.classList.add('opacity-50');
button.innerHTML = '<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">' +
'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' +
'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>' +
'</svg>';
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
const message = {
type: 'kick_player',
participant_id: participantId,
host_id: '{{ quiz_game.host_id }}'
};
console.log('Sending kick message:', message);
lobbySocket.send(JSON.stringify(message));
} else {
console.error('WebSocket is not connected');
button.disabled = false;
button.classList.remove('opacity-50');
alert('Verbindungsfehler. Bitte versuche es erneut.');
}
}
}
function updateParticipantsList(participants) {
const list = document.getElementById('participants-list');
if (!list) {
console.warn('Participants list element not found');
return;
}
if (!participants || participants.length === 0) {
list.innerHTML = '<li class="text-gray-500 p-3 bg-gray-50 rounded-lg text-center">Noch keine Teilnehmer...</li>';
return;
}
try {
// Finde neue und entfernte Teilnehmer
const currentParticipants = Array.from(list.children)
.map(li => li.textContent)
.filter(name => name !== 'Noch keine Teilnehmer...' && name !== 'Fehler beim Aktualisieren der Liste');
const newParticipants = participants
.filter(p => !currentParticipants.includes(p.display_name))
.map(p => p.display_name);
const removedParticipants = currentParticipants
.filter(name => !participants.find(p => p.display_name === name));
// Aktualisiere die Liste mit animierten Einträgen
list.innerHTML = participants
.filter(p => p && p.display_name) // Filter out invalid participants
.map(function(p) {
if (!p || !p.display_name) {
console.error('Invalid participant data:', p);
return '';
}
const displayName = p.display_name;
const initial = displayName.charAt(0) || '?';
const participantId = p.participant_id;
// Check if this entry is for the host
const isHost = participantId === '{{ quiz_game.host_id }}';
const isCurrentUserHost = document.cookie.includes('host_id={{ quiz_game.host_id }}');
console.log('Participant check:', {
participantId,
displayName,
isHost,
isCurrentUserHost,
gameHostId: '{{ quiz_game.host_id }}'
});
// Show kick button if current user is host and this is not their own entry
let kickButton = '';
if (isCurrentUserHost && !isHost && participantId) {
kickButton = '<button onclick="kickPlayer(\'' + p.participant_id + '\')" ' +
'class="p-1.5 rounded-full text-red-600 hover:text-white hover:bg-red-600 ' +
'transition-all duration-200 group relative" title="Spieler kicken">' +
'<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="M6 18L18 6M6 6l12 12"/>' +
'</svg>' +
'<span class="absolute -top-8 left-1/2 transform -translate-x-1/2 px-2 py-1 ' +
'bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 ' +
'transition-opacity duration-200 whitespace-nowrap">Spieler kicken</span>' +
'</button>';
}
return '<li class="p-3 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition duration-200 flex items-center justify-between animate-fade-in">' +
'<div class="flex items-center">' +
'<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">' +
'<span class="text-blue-600 font-bold">' + initial.toUpperCase() + '</span>' +
'</div>' +
'<span class="font-medium">' + displayName + '</span>' +
'</div>' +
'<div class="flex items-center space-x-2">' +
(isHost ? '<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded-full">Host</span>' : '') +
'<span class="w-2 h-2 bg-green-500 rounded-full ml-2"></span>' +
kickButton +
'</div>' +
'</li>';
})
.join('');
// Benachrichtigungen werden über WebSocket-Events gehandelt
} catch (error) {
console.error('Error updating participants list:', error);
list.innerHTML = '<li class="text-red-500">Fehler beim Aktualisieren der Liste</li>';
}
}
{% if host_id %}
const startButton = document.getElementById('start-button');
if (startButton) {
startButton.addEventListener('click', function() {
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
startButton.disabled = true;
startButton.classList.add('opacity-50');
startButton.innerHTML =
'<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">' +
'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' +
'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>' +
'</svg>' +
'Spiel wird gestartet...';
lobbySocket.send(JSON.stringify({
type: 'start_game',
host_id: '{{ host_id }}'
}));
}
});
}
{% endif %}
{% if participant %}
const leaveButton = document.getElementById('leave-button');
if (leaveButton) {
leaveButton.addEventListener('click', function() {
if (lobbySocket && lobbySocket.readyState === WebSocket.OPEN) {
leaveButton.disabled = true;
leaveButton.classList.add('opacity-50');
leaveButton.innerHTML =
'<svg class="animate-spin h-5 w-5 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">' +
'<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>' +
'<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>' +
'</svg>' +
'Verlasse Spiel...';
lobbySocket.send(JSON.stringify({
type: 'leave_game',
participant_id: '{{ participant.participant_id }}'
}));
// Warte kurz und leite dann zur Startseite weiter
setTimeout(() => {
window.location.href = '/';
}, 500);
}
});
}
{% endif %}
</script>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More