Compare commits

..

1 Commits

Author SHA1 Message Date
nik8
69dc2530ce Schrift in Kasten 2025-03-16 17:48:31 +01:00
111 changed files with 432 additions and 7211 deletions

5
.gitignore vendored
View File

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

View File

@@ -7,7 +7,6 @@
3. [Superuser erstellen](#superuser-erstellen) 3. [Superuser erstellen](#superuser-erstellen)
4. [Tailwind-Nutzung](#tailwind-nutzung) 4. [Tailwind-Nutzung](#tailwind-nutzung)
5. [Issue - Merge Request - Merge](#issue-mergerequest-merge) 5. [Issue - Merge Request - Merge](#issue-mergerequest-merge)
6. [Konfiguration](#konfiguration)
## Initialisierung des Projekts ## Initialisierung des Projekts
@@ -48,62 +47,16 @@ Um das Projekt erfolgreich zu starten, folge diesen Schritten:
### Entwicklungsserver starten ### 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: - Standardport (8000):
```sh ```sh
source .venv/bin/activate # Linux python3 core/manage.py runserver
.venv\Scripts\activate # Windows ```
``` - Bestimmten Port (z. B. 9000):
```sh
2. Starte den Daphne-Server: python3 core/manage.py runserver 9000
```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!
Damit ist das Projekt erfolgreich eingerichtet und der Server kann gestartet werden! 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 ```sh
# unter Linux mit Tailwind Binärdatei: # 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 '\'): # unter Windows mit NPM:
npx @tailwindcss/cli -i ./static/css/t-input.css -o ./static/css/t-style.css --watch --minify npx @tailwindcss/cli -i .\core\static\homepage\t-input.css -o .\core\static\homepage\t-style.css --watch --minify
``` ```
## Issue - Merge Request - Merge ## 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. 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. 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,7 +0,0 @@
Name und Anschrift des Verantwortlichen
//NAME HIER EINSETZEN//
//ADRESSE HIER EINSETZEN//
//E-MAIL HIER EINSETZEN//

View File

@@ -6,15 +6,11 @@ from django import forms
class RegisterForm(UserCreationForm): class RegisterForm(UserCreationForm):
class Meta: class Meta:
model=User model=User
fields = ['username','password1','password2', "email"] fields = ['username','password1','password2']
username = forms.CharField( username = forms.CharField(
widget=forms.TextInput(attrs={'placeholder': 'BENUTZERNAME'}) widget=forms.TextInput(attrs={'placeholder': 'BENUTZERNAME'})
) )
email = forms.EmailField(
widget=forms.TextInput(attrs={'placeholder': 'E-MAIL'})
)
password1 = forms.CharField( password1 = forms.CharField(
widget=forms.PasswordInput(attrs={'placeholder': 'PASSWORT'}) widget=forms.PasswordInput(attrs={'placeholder': 'PASSWORT'})
) )
@@ -23,3 +19,4 @@ class RegisterForm(UserCreationForm):
) )

View File

@@ -1,8 +1,7 @@
from django.urls import path, reverse_lazy # type: ignore from django.urls import path # type: ignore
from . import views from . import views
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.contrib.auth import views as auth_views
app_name = 'accounts' # Namensraum zum verhindern von Konflikten zwischen Apps app_name = 'accounts' # Namensraum zum verhindern von Konflikten zwischen Apps
# #
@@ -14,22 +13,4 @@ urlpatterns = [
path('logout/', LogoutView.as_view(next_page='accounts:login'), name='logout'), path('logout/', LogoutView.as_view(next_page='accounts:login'), name='logout'),
path('login/', LoginView.as_view(template_name='accounts/login.html', next_page='accounts:home'), name='login'), path('login/', LoginView.as_view(template_name='accounts/login.html', next_page='accounts:home'), name='login'),
path('register/', views.register, name='register'), path('register/', views.register, name='register'),
path('password_reset/', auth_views.PasswordResetView.as_view(success_url=reverse_lazy('accounts:password_reset_done')), name='password_reset'), ]
path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'),
path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
path('password_change/', views.password_changed, name='password_change'),
path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(), name='password_change_done'),
path('delete_account/', views.deleteAccount, name="delete_account"),
path(
'reset/<uidb64>/<token>/',
auth_views.PasswordResetConfirmView.as_view(
template_name='registration/password_reset_confirm.html',
success_url=reverse_lazy('accounts:password_reset_complete')
),
name='password_reset_confirm'
),
]

View File

@@ -1,59 +1,19 @@
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, authenticate
from .forms import RegisterForm from .forms import RegisterForm
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth import update_session_auth_hash
from django.contrib import messages
from django.contrib.auth import logout
# Create your views here. # Create your views here.
@login_required @login_required
def home(request): def home(request):
return render(request, 'accounts/home.html') return render(request, 'accounts/home.html')
def register(response): def register(response):
if response.method == "POST": if response.method == "POST":
form = RegisterForm(response.POST) form = RegisterForm(response.POST)
if form.is_valid(): if form.is_valid():
user=form.save() form.save()
login(response, user) #automatischer Login nach Registrierung return redirect("home")
return redirect("accounts:home")
else: else:
form = RegisterForm() form = RegisterForm()
return render(response, "accounts/register.html", {"form":form}) return render(response, "accounts/register.html", {"form":form})
from django.contrib.auth import views as auth_views
@login_required
def password_changed(request):
if request.method == 'POST':
form = PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
user = form.save()
#update_session_auth_hash(request, user) #Hier wäre der User nach Passwortänderung noch angemeldet!
logout(request)
messages.success(request, "Das Passwort wurde erfolgreich geändert!")
return redirect('accounts:home')
else:
form = PasswordChangeForm(user=request.user)
return render(request, 'registration/password_change_form.html', {'form': form})
@login_required
def deleteAccount(request):
if request.method == 'POST':
user=request.user
logout(request)
user.delete()
messages.success(request, "Der Account wurde erfolgreich gelöscht!")
return redirect('homepage:home')
return render(request, 'registration/delete_account_confirm.html')

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ComponentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'components'

View File

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
django/components/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import path # type: ignore
from . import views
app_name = 'components' # Namensraum zum verhindern von Konflikten zwischen Apps
#
# Wichtig: Damit Links funktionieren müssen diese so eingebunden werden: {% url 'accounts:login' %} statt {% url 'login' %}
#
urlpatterns = [
path('<int:pk>', views.play, name='play'),
path('ranking/<int:pk>', views.show_rank, name='ranking')
]

View File

@@ -0,0 +1,9 @@
from django.shortcuts import render
# Create your views here.
def play(request,pk): #
return render(request, 'components/answer.html', {"pk": pk}) #
def show_rank(request,pk): #
return render(request, 'components/ranking.html', {"pk": pk}) #

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 import os
from django.core.asgi import get_asgi_application 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') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
django.setup()
from play.routing import websocket_urlpatterns application = get_asgi_application()
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
websocket_urlpatterns
)
),
})

View File

@@ -10,13 +10,11 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/ https://docs.djangoproject.com/en/5.1/ref/settings/
""" """
import json
from pathlib import Path from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' #TODO: NUR FÜR ENTWICKLUNG
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
@@ -29,15 +27,13 @@ DEBUG = True
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '*'] 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 # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'library.apps.LibraryConfig', 'library.apps.LibraryConfig',
'homepage.apps.HomepageConfig', 'homepage.apps.HomepageConfig',
'components.apps.ComponentsConfig',
'accounts.apps.AccountsConfig', 'accounts.apps.AccountsConfig',
'play', 'play',
'django.contrib.admin', 'django.contrib.admin',
@@ -46,13 +42,10 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'channels',
'django_cleanup',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -80,17 +73,6 @@ TEMPLATES = [
] ]
WSGI_APPLICATION = 'core.wsgi.application' 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 # Database
@@ -103,15 +85,6 @@ DATABASES = {
} }
} }
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
}
# Password validation # Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators # 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/ # https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / 'static'] 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 # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = '/account/login/' 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 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 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.contrib import admin # type: ignore
from django.urls import path, include from django.urls import path, include
@@ -26,6 +24,3 @@ urlpatterns = [
path('play/', include('play.urls')), path('play/', include('play.urls')),
path('library/', include('library.urls')), path('library/', include('library.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 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 # Für das QivipQuiz Modell
class QivipQuizAdmin(admin.ModelAdmin): class QivipQuizAdmin(admin.ModelAdmin):
list_display = ('name', 'user_id', 'status', 'category', 'creation_date', 'update_date') # Welche Felder sollen in der Übersicht angezeigt werden 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 search_fields = ('name', 'user_id__username', 'category__name') # Suchfelder
list_filter = ('status', 'category') # Filteroptionen list_filter = ('status', 'category') # Filteroptionen
# Für das QivipQuestion Modell # Für das QivipQuestion Modell
class QivipQuestionAdmin(admin.ModelAdmin): 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',) search_fields = ('data',)
list_filter = ('quiz_id',) list_filter = ('quiz_id',)
@@ -18,22 +19,13 @@ class QuizCategoryAdmin(admin.ModelAdmin):
search_fields = ('name',) search_fields = ('name',)
prepopulated_fields = {'slug': ('name',)} # Erstelle automatisch den Slug basierend auf dem Namen prepopulated_fields = {'slug': ('name',)} # Erstelle automatisch den Slug basierend auf dem Namen
# Für das Tag Modell
class TagAdmin(admin.ModelAdmin):
class QivipQuizFavoriteAdmin(admin.ModelAdmin): list_display = ('name',) # Zeige nur den Namen des Tags
list_display = ('user', 'favorite', 'quiz') # Welche Felder sollen in der Übersicht angezeigt werden search_fields = ('name',)
class QuizImageAdmin(admin.ModelAdmin):
list_display = ('image',)
class QuestionImageAdmin(admin.ModelAdmin):
list_display = ('image',)
# Registrierung der Modelle im Admin # Registrierung der Modelle im Admin
admin.site.register(QivipQuiz, QivipQuizAdmin) admin.site.register(QivipQuiz, QivipQuizAdmin)
admin.site.register(QivipQuestion, QivipQuestionAdmin) admin.site.register(QivipQuestion, QivipQuestionAdmin)
admin.site.register(QuizCategory, QuizCategoryAdmin) admin.site.register(QuizCategory, QuizCategoryAdmin)
admin.site.register(QivipQuizFavorite, QivipQuizFavoriteAdmin) admin.site.register(Tag, TagAdmin)
admin.site.register(QuizImage, QuizImageAdmin)
admin.site.register(QuestionImage, QuestionImageAdmin)

View File

@@ -4,28 +4,9 @@ from .models import QivipQuiz, QivipQuestion
class QuizForm(forms.ModelForm): class QuizForm(forms.ModelForm):
class Meta: class Meta:
model = QivipQuiz model = QivipQuiz
fields = ['name', 'description', 'status', 'category','difficulty','credits'] fields = ['name', 'description', 'status', 'category', 'tags',"difficulty"]
class QuestionForm(forms.ModelForm): class QuestionForm(forms.ModelForm):
class Meta: class Meta:
model = QivipQuestion model = QivipQuestion
fields = ['quiz_id', 'data','time_per_question'] fields = ['quiz_id', 'data']
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 ...', 'list': 'quiz-names',
}))
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'
}))

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-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

@@ -4,19 +4,10 @@ from django.utils.text import slugify
import uuid import uuid
from django.core.exceptions import ValidationError 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): class QivipQuiz(models.Model):
STATUS_VALUES = { STATUS_VALUES = {
"öffentlich": "Öffentlich", "öffentlich": "Öffentlich",
#"versteckt": "Versteckt", "versteckt": "Versteckt",
"privat": "Privat", "privat": "Privat",
} }
DIFFICULTY_VALUES = { DIFFICULTY_VALUES = {
@@ -28,103 +19,43 @@ class QivipQuiz(models.Model):
"nicht gesetzt":"nicht gesetzt", "nicht gesetzt":"nicht gesetzt",
} }
def validate_description(value): def validate_description(value):
if value.strip() == "In dem Quiz geht es um ...": if value.strip() == "In dem Quiz geht es um ...":
raise ValidationError("Bitte gib eine aussagekräftige Beschreibung ein.") 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) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
user_id = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quiz') 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) creation_date = models.DateTimeField(auto_now_add=True)
update_date = models.DateTimeField(auto_now=True) update_date = models.DateTimeField(auto_now=True)
status = models.CharField(max_length=10, choices=list(STATUS_VALUES.items()), default="öffentlich") 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) category = models.ForeignKey('QuizCategory', related_name='quiz', on_delete=models.CASCADE)
name = models.CharField(max_length=75,verbose_name="Name des Quizzes") tags = models.ManyToManyField('Tag', blank=True)
description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...", verbose_name="Beschreibung") name = models.CharField(max_length=75)
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") description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...")
credits=models.TextField(blank=True, editable=True) difficulty= models.CharField(max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit")
average_rating = models.FloatField(default=3.0)
rating_count = models.IntegerField(default=0)
def __str__(self): def __str__(self):
return self.name 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. # Create your models here.
class QivipQuestion(models.Model): 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) 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) creation_date = models.DateTimeField(auto_now_add=True)
update_date = models.DateTimeField(auto_now=True) update_date = models.DateTimeField(auto_now=True)
data = models.TextField() 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): def __str__(self):
return self.data[:50] return self.data[:50]
class Tag(models.Model):
name = models.CharField(max_length=100, unique=True)
def __str__(self):
class QuizRating(models.Model): return self.name
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()
class QuizCategory(models.Model): class QuizCategory(models.Model):
name = models.CharField(max_length=100, unique=True) name = models.CharField(max_length=100, unique=True)

View File

@@ -1,8 +1,6 @@
from django.urls import path from django.urls import path
from django.conf import settings
from . import views from . import views
from django.conf.urls.static import static
app_name = 'library' app_name = 'library'
urlpatterns = [ urlpatterns = [
@@ -14,8 +12,4 @@ urlpatterns = [
path('question/new/', views.new_question, name='new_question'), path('question/new/', views.new_question, name='new_question'),
path('question/edit/<int:pk>/', views.edit_question, name='edit_question'), path('question/edit/<int:pk>/', views.edit_question, name='edit_question'),
path('question/delete/<int:pk>/', views.delete_question, name='delete_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'),
path("quiz-names-json/", views.quiz_names_json, name="quiz_names_json"),
] ]

View File

@@ -1,201 +1,35 @@
from django.utils import timezone
import uuid
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse from django.urls import reverse
from django.contrib import messages from django.contrib import messages
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizImage from .models import QivipQuiz, QivipQuestion
from .forms import QuizForm, QuestionForm from .forms import QuizForm, QuestionForm
import json import json
from django.core.paginator import Paginator 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 # Übersicht aller Quizze
def quiz_names_json(request):
form = QuizFilterForm(request.GET)
if form.is_valid():
search = form.cleaned_data.get('search')
names = list(
QivipQuiz.objects.all().annotate(max_amout_questions=Count('questions')).filter(max_amout_questions__gte=1, status='öffentlich', name__icontains=search)
.values_list('name', flat=True)
.distinct()
)
return JsonResponse(names, safe=False)
def overview_quiz(request): def overview_quiz(request):
# Filter
form = QuizFilterForm(request.GET)
try: try:
favorite_quizzes_data = QivipQuizFavorite.objects.filter(user=request.user, favorite=True).order_by("-favorite_date") quizzes = QivipQuiz.objects.filter(user_id=request.user)
favorite_quiz_ids = favorite_quizzes_data.values_list('quiz', flat=True) all_quizzes = QivipQuiz.objects.filter(status='öffentlich').exclude(user_id=request.user).filter(question__isnull=False).distinct()
favorite_quizzes = QivipQuiz.objects.filter(id__in=favorite_quiz_ids) return render(request, 'library/overview_quiz.html', {'quizzes': quizzes, 'all_quizzes': all_quizzes})
except: except:
favorite_quizzes_data = QivipQuizFavorite.objects.none() all_quizzes = QivipQuiz.objects.filter(status='öffentlich').filter(question__isnull=False).distinct()
favorite_quizzes = QivipQuizFavorite.objects.none() return render(request, 'library/overview_quiz.html', {'quizzes': None, 'all_quizzes': all_quizzes})
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()
# 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()
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:
favorite=QivipQuizFavorite.objects.create(user=request.user, quiz=quiz, favorite=True, favorite_date=timezone.now())
statement="Das Quiz '"+ favorite.quiz.name+ "' wurde erfolgreich zu den Favoriten hinzugefügt!"
messages.success(request,statement )
return redirect(request.META.get('HTTP_REFERER', 'library:overview_quiz'))
# Neues Quiz erstellen # Neues Quiz erstellen
@login_required @login_required
def new_quiz(request): def new_quiz(request):
if request.method == 'POST': if request.method == 'POST':
form = QuizForm(request.POST)
form = QuizForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
quiz = form.save(commit=False) 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.user_id = request.user
#quiz.creator = request.user
quiz.save() quiz.save()
#form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags) form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
return redirect('library:detail_quiz', pk=quiz.pk) return redirect('library:edit_quiz', pk=quiz.pk)
else: else:
form = QuizForm() form = QuizForm()
return render(request, 'library/form.html', {'form': form}) return render(request, 'library/form.html', {'form': form})
@@ -205,112 +39,40 @@ def new_quiz(request):
def edit_quiz(request, pk): def edit_quiz(request, pk):
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user) quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
if request.method == 'POST': if request.method == 'POST':
form = QuizForm(request.POST, instance=quiz, files=request.FILES) form = QuizForm(request.POST, instance=quiz)
if form.is_valid(): 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() form.save()
for img in QuizImage.objects.all(): return redirect('library:overview_quiz')
try:
img.delete()
img.image.delete(save=False)
except:
pass
return redirect('library:detail_quiz', pk=pk)
#return modified(request, pk)
else: else:
form = QuizForm(instance=quiz) form = QuizForm(instance=quiz)
return render(request, 'library/form.html', {'form': form}) return render(request, 'library/form.html', {'form': form})
# Quiz löschen # Quiz löschen
@login_required @login_required
def delete_quiz(request, pk): def delete_quiz(request, pk):
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user) quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
if request.method == 'POST': if request.method == 'POST':
quiz.delete() 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 redirect('library:overview_quiz')
return render(request, 'library/delete_confirmation.html', {'object': quiz}) return render(request, 'library/delete_confirmation.html', {'object': quiz})
# Quiz anzeigen # Quiz anzeigen
@login_required
def detail_quiz(request, pk): def detail_quiz(request, pk):
#quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user) quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
quiz = get_object_or_404(QivipQuiz, pk=pk)
show_answers = request.GET.get('show_answers', 'false').lower() == 'true'
questions = QivipQuestion.objects.filter(quiz_id=quiz) questions = QivipQuestion.objects.filter(quiz_id=quiz)
# Parse JSON data for each question # Parse JSON data for each question
for question in questions: for question in questions:
if question.data: if question.data:
question.data = json.loads(question.data) question.data = json.loads(question.data)
context = { context = {
'quiz': quiz, 'quiz': quiz,
'questions': questions, 'questions': questions
'detail':show_answers,
} }
return render(request, 'library/detail_quiz.html', context) 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 # Übersicht aller Fragen
@login_required @login_required
def question_list(request): def question_list(request):
@@ -339,10 +101,10 @@ def new_question(request):
messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.') messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.')
return redirect('library:overview_quiz') return redirect('library:overview_quiz')
if question_type not in ['multiple_choice', 'true_false','input']: if question_type not in ['multiple_choice', 'true_false']:
base_url = reverse('library:new_question') base_url = reverse('library:new_question')
return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}') return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}')
if request.method == 'POST': if request.method == 'POST':
question_text = request.POST.get('question', '') question_text = request.POST.get('question', '')
@@ -357,17 +119,6 @@ def new_question(request):
{'value': 'Falsch', 'is_correct': not correct_answer} {'value': 'Falsch', 'is_correct': not correct_answer}
] ]
} }
elif question_type == 'input':
correct_answer = request.POST.get('correct_answer')
json_data = {
'type': question_type,
'question': question_text,
'options': [
{'value': correct_answer, 'is_correct': True}
]
}
elif question_type == 'multiple_choice': elif question_type == 'multiple_choice':
options = [] options = []
i = 1 i = 1
@@ -395,17 +146,7 @@ def new_question(request):
question = QivipQuestion() question = QivipQuestion()
question.data = json.dumps(json_data) question.data = json.dumps(json_data)
question.quiz_id = quiz 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() question.save()
#return modified(request, pk=quiz.pk)
return redirect('library:detail_quiz', pk=quiz.pk) return redirect('library:detail_quiz', pk=quiz.pk)
else: else:
# Initialize empty question data for new questions # Initialize empty question data for new questions
@@ -418,19 +159,6 @@ def new_question(request):
{'value': 'Falsch', 'is_correct': False} {'value': 'Falsch', 'is_correct': False}
] ]
} }
standard_time_per_question = 30
elif question_type == 'input':
question_data = {
'type': question_type,
'question': '',
'options': [
]
}
standard_time_per_question = 30
elif question_type == 'multiple_choice': elif question_type == 'multiple_choice':
question_data = { question_data = {
'type': question_type, 'type': question_type,
@@ -440,14 +168,12 @@ def new_question(request):
{'value': '', 'is_correct': False} {'value': '', 'is_correct': False}
] ]
} }
standard_time_per_question = 30
template_name = f'library/question/question_{question_type}.html' template_name = f'library/question/question_{question_type}.html'
context = { context = {
'question': question_data, 'question': question_data,
'quiz_id': quiz_id, 'quiz_id': quiz_id,
'quiz': quiz, 'quiz': quiz
'standard_time_per_question': standard_time_per_question,
} }
return render(request, template_name, context) return render(request, template_name, context)
@@ -456,8 +182,6 @@ def new_question(request):
@login_required @login_required
def edit_question(request, pk): def edit_question(request, pk):
question = get_object_or_404(QivipQuestion, pk=pk, quiz_id__user_id=request.user) 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: if question.quiz_id.user_id != request.user:
return redirect('library:question_list') return redirect('library:question_list')
@@ -465,7 +189,6 @@ def edit_question(request, pk):
try: try:
question_data = json.loads(question.data) if question.data else {} question_data = json.loads(question.data) if question.data else {}
question_type = question_data.get('type', 'multiple_choice') question_type = question_data.get('type', 'multiple_choice')
question_image = question.image
# For true/false questions, get the correct answer from the options # For true/false questions, get the correct answer from the options
if question_type == 'true_false': if question_type == 'true_false':
@@ -510,13 +233,7 @@ def edit_question(request, pk):
if request.method == 'POST': if request.method == 'POST':
question_text = request.POST.get('question', '') 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 # Handle different question types
if question_type == 'true_false': if question_type == 'true_false':
correct_answer = request.POST.get('correct_answer') == 'true' correct_answer = request.POST.get('correct_answer') == 'true'
@@ -528,17 +245,6 @@ def edit_question(request, pk):
{'value': 'Falsch', 'is_correct': not correct_answer} {'value': 'Falsch', 'is_correct': not correct_answer}
] ]
} }
elif question_type == 'input':
correct_answer = request.POST.get('correct_answer')
json_data = {
'type': question_type,
'question': question_text,
'options': [
{'value': correct_answer,'is_correct':True}
]
}
elif question_type == 'multiple_choice': elif question_type == 'multiple_choice':
options = [] options = []
i = 1 i = 1
@@ -568,32 +274,18 @@ def edit_question(request, pk):
} }
question.data = json.dumps(json_data) question.data = json.dumps(json_data)
question.time_per_question = request.POST.get('time_per_question', '30')
question.save() 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) return redirect('library:detail_quiz', pk=question.quiz_id.pk)
else: else:
template_name = f'library/question/question_{question_type}.html' template_name = f'library/question/question_{question_type}.html'
context = { context = {
'question': {'data': question_data}, # Wrap in data structure expected by template 'question': {'data': question_data}, # Wrap in data structure expected by template
'quiz_id': question.quiz_id.pk, 'quiz_id': question.quiz_id.pk,
'quiz': question.quiz_id, 'quiz': question.quiz_id
'time_per_question': question.time_per_question,
'image': question_image,
} }
return render(request, template_name, context) return render(request, template_name, context)
# Frage löschen # Frage löschen
@login_required @login_required
def delete_question(request, pk): def delete_question(request, pk):
@@ -605,11 +297,5 @@ def delete_question(request, pk):
if request.method == 'POST': if request.method == 'POST':
question.delete() 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 redirect('library:detail_quiz', pk=question.quiz_id.pk)
return render(request, 'library/delete_confirmation.html', {'object': question}) return render(request, 'library/delete_confirmation.html', {'object': question})

916
django/package-lock.json generated
View File

@@ -1,916 +0,0 @@
{
"name": "django",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "django",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@tailwindcss/cli": "^4.1.5",
"tailwindcss": "^4.1.5"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
"integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
"hasInstallScript": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"@parcel/watcher-android-arm64": "2.5.1",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-freebsd-x64": "2.5.1",
"@parcel/watcher-linux-arm-glibc": "2.5.1",
"@parcel/watcher-linux-arm-musl": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-arm64-musl": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1",
"@parcel/watcher-win32-arm64": "2.5.1",
"@parcel/watcher-win32-ia32": "2.5.1",
"@parcel/watcher-win32-x64": "2.5.1"
}
},
"node_modules/@parcel/watcher-android-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
"integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
"integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-darwin-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
"integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-freebsd-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
"integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
"integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
"integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
"integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-arm64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
"integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
"integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
"integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-arm64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
"integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-ia32": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
"integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/watcher-win32-x64": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
"integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.5.tgz",
"integrity": "sha512-Kr567rDwDjY1VUnfqh5/+DCpRf4B8lPs5O9flP4kri7n4AM2aubrIxGSh5GN8s+awUKw/U4+6kNlEnZbBNfUeg==",
"dependencies": {
"@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "4.1.5",
"@tailwindcss/oxide": "4.1.5",
"enhanced-resolve": "^5.18.1",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.1.5"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.5.tgz",
"integrity": "sha512-CBhSWo0vLnWhXIvpD0qsPephiaUYfHUX3U9anwDaHZAeuGpTiB3XmsxPAN6qX7bFhipyGBqOa1QYQVVhkOUGxg==",
"dependencies": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"lightningcss": "1.29.2",
"tailwindcss": "4.1.5"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.5.tgz",
"integrity": "sha512-1n4br1znquEvyW/QuqMKQZlBen+jxAbvyduU87RS8R3tUSvByAkcaMTkJepNIrTlYhD+U25K4iiCIxE6BGdRYA==",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.5",
"@tailwindcss/oxide-darwin-arm64": "4.1.5",
"@tailwindcss/oxide-darwin-x64": "4.1.5",
"@tailwindcss/oxide-freebsd-x64": "4.1.5",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.5",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.5",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.5",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.5",
"@tailwindcss/oxide-linux-x64-musl": "4.1.5",
"@tailwindcss/oxide-wasm32-wasi": "4.1.5",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.5",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.5"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.5.tgz",
"integrity": "sha512-LVvM0GirXHED02j7hSECm8l9GGJ1RfgpWCW+DRn5TvSaxVsv28gRtoL4aWKGnXqwvI3zu1GABeDNDVZeDPOQrw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.5.tgz",
"integrity": "sha512-//TfCA3pNrgnw4rRJOqavW7XUk8gsg9ddi8cwcsWXp99tzdBAZW0WXrD8wDyNbqjW316Pk2hiN/NJx/KWHl8oA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.5.tgz",
"integrity": "sha512-XQorp3Q6/WzRd9OalgHgaqgEbjP3qjHrlSUb5k1EuS1Z9NE9+BbzSORraO+ecW432cbCN7RVGGL/lSnHxcd+7Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.5.tgz",
"integrity": "sha512-bPrLWbxo8gAo97ZmrCbOdtlz/Dkuy8NK97aFbVpkJ2nJ2Jo/rsCbu0TlGx8joCuA3q6vMWTSn01JY46iwG+clg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.5.tgz",
"integrity": "sha512-1gtQJY9JzMAhgAfvd/ZaVOjh/Ju/nCoAsvOVJenWZfs05wb8zq+GOTnZALWGqKIYEtyNpCzvMk+ocGpxwdvaVg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.5.tgz",
"integrity": "sha512-dtlaHU2v7MtdxBXoqhxwsWjav7oim7Whc6S9wq/i/uUMTWAzq/gijq1InSgn2yTnh43kR+SFvcSyEF0GCNu1PQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.5.tgz",
"integrity": "sha512-fg0F6nAeYcJ3CriqDT1iVrqALMwD37+sLzXs8Rjy8Z1ZHshJoYceodfyUwGJEsQoTyWbliFNRs2wMQNXtT7MVA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.5.tgz",
"integrity": "sha512-SO+F2YEIAHa1AITwc8oPwMOWhgorPzzcbhWEb+4oLi953h45FklDmM8dPSZ7hNHpIk9p/SCZKUYn35t5fjGtHA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.5.tgz",
"integrity": "sha512-6UbBBplywkk/R+PqqioskUeXfKcBht3KU7juTi1UszJLx0KPXUo10v2Ok04iBJIaDPkIFkUOVboXms5Yxvaz+g==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.5.tgz",
"integrity": "sha512-hwALf2K9FHuiXTPqmo1KeOb83fTRNbe9r/Ixv9ZNQ/R24yw8Ge1HOWDDgTdtzntIaIUJG5dfXCf4g9AD4RiyhQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@emnapi/wasi-threads": "^1.0.2",
"@napi-rs/wasm-runtime": "^0.2.9",
"@tybys/wasm-util": "^0.9.0",
"tslib": "^2.8.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.5.tgz",
"integrity": "sha512-oDKncffWzaovJbkuR7/OTNFRJQVdiw/n8HnzaCItrNQUeQgjy7oUiYpsm9HUBgpmvmDpSSbGaCa2Evzvk3eFmA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.5.tgz",
"integrity": "sha512-WiR4dtyrFdbb+ov0LK+7XsFOsG+0xs0PKZKkt41KDn9jYpO7baE3bXiudPVkTqUEwNfiglCygQHl2jklvSBi7Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"bin": {
"detect-libc": "bin/detect-libc.js"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.2"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"engines": {
"node": ">=8"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"engines": {
"node": ">=4"
}
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tailwindcss": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.5.tgz",
"integrity": "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
}
}
}

View File

@@ -1,16 +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": {
"@tailwindcss/cli": "^4.1.5",
"tailwindcss": "^4.1.5"
}
}

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,412 +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']
try:
answer = text_data_json['answer']
except:
answer=-1
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
question_data = json.loads(current_question.data)
print(answer_index)
try:
if answer_index >= 0 : # -1 means timeout
is_correct = question_data['options'][answer_index]['is_correct']
print(is_correct)
except:
user_input = str(answer_index).strip().lower().replace(" ", "")
correct_value = str(question_data['options'][0].get('value', '')).strip().lower().replace(" ", "")
print("Richtig",correct_value)
print(user_input)
if user_input == correct_value:
is_correct=True
answer_index=int(0)
print(is_correct)
else:
answer_index=int(1)
# Calculate score based on correctness and time
score = 0
if is_correct:
base_score = 1000
time_factor = time_remaining / int(current_question.time_per_question)/int(1000)
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,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

@@ -6,24 +6,13 @@ import random, string
# Create your models here. # Create your models here.
class QuizGame(models.Model): class QuizGame(models.Model):
GAME_STATES = [ host_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='host_user')
('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) join_code = models.CharField(max_length=6, unique=True, blank=True)
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE) 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): def save(self, *args, **kwargs):
if not self.join_code: if not self.join_code:
self.join_code = self.generate_unique_code() self.join_code = self.generate_unique_code()
super().save(*args, **kwargs) super().save(*args, **kwargs)
def generate_unique_code(self): def generate_unique_code(self):
@@ -32,43 +21,17 @@ class QuizGame(models.Model):
if not QuizGame.objects.filter(join_code=new_code).exists(): if not QuizGame.objects.filter(join_code=new_code).exists():
return new_code 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): class QuizGameParticipant(models.Model):
participant_id = models.CharField(max_length=200, unique=True) participant_id = models.CharField(max_length=200, unique=True)
display_name = models.CharField(verbose_name="Anzeigename", max_length=15) display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants") quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant")
score = models.IntegerField(verbose_name="Punkte", default=0) score = models.IntegerField(verbose_name="Punkte", default=0)
avatar = models.CharField(max_length=200, blank=True, default="") avatar = models.CharField(max_length=200, blank=True, default="")
last_heartbeat = models.DateTimeField(auto_now_add=True)
last_answer = models.IntegerField(null=True, blank=True)
last_answer_correct = models.BooleanField(null=True, blank=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if not self.participant_id: if not self.participant_id:
self.participant_id = self.generate_unique_id() self.participant_id = self.generate_unique_id()
super().save(*args, **kwargs) 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): def generate_unique_id(self):
for i in range(10): for i in range(10):

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,7 @@ from . import views
app_name = 'play' app_name = 'play'
urlpatterns = [ urlpatterns = [
path('game/new/<int:quiz_id>', views.create_game, name='create_game'), path('lobby/<str:join_code>', views.lobby, name='lobby'),
path('game/<str:join_code>', views.lobby, name='lobby'), path('lobby/<str:join_code>/participate', views.create_participant, name='create_participant'),
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
path('join', views.join_game, name='join_game'), 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,60 @@
from django.utils import timezone
from django.shortcuts import render from django.shortcuts import render
from django.shortcuts import render, redirect, get_object_or_404, reverse from django.shortcuts import render, redirect, get_object_or_404, reverse
from play.models import QuizGame, QuizGameParticipant from play.models import QuizGame, QuizGameParticipant
from library.models import QivipQuiz from library.models import QivipQuiz
from django.http import HttpResponseRedirect, HttpResponseNotFound from django.http import HttpResponseRedirect
from django.contrib import messages
from django.conf import settings
import json
# Create your views here. # Create your views here.
def lobby(request, join_code): def lobby(request, join_code):
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
quiz = 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: if not "participant_id" in request.COOKIES:
return redirect('play:create_participant', join_code=join_code) return redirect('play:create_participant', join_code=join_code)
participant_id = request.COOKIES['participant_id'] 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) quiz_game = get_object_or_404(QuizGame, join_code=join_code)
if mode!="learn": quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
if "participant_id" in request.COOKIES: participant = get_object_or_404(QuizGameParticipant, participant_id=participant_id)
# 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': if not participant.quiz_game.join_code == join_code:
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.quiz_game = quiz_game
participant.score = 0 # Initialize score
participant.save() participant.save()
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code})) context = {
response.set_cookie('participant_id', participant.participant_id, max_age=3600) 'debug': "test",
return response 'participant': participant,
'quiz': quiz,
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: return render(request, 'play/lobby.html', context=context)
quiz = QivipQuiz.objects.get(id=quiz_id)
game = QuizGame()
game.quiz_id = quiz
game.host_id = game.generate_unique_id()
game.save()
def create_participant(request, join_code):
if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt
return redirect('play:lobby', join_code=join_code)
if request.method == 'POST':
display_name = request.POST.get('display_name')
join_code = request.POST.get('join_code')
try:
quiz = QuizGame.objects.get(join_code=join_code)
participant = QuizGameParticipant()
participant.display_name = display_name
participant.quiz_game = quiz
participant.save()
response = HttpResponseRedirect(reverse('play:select_mode', kwargs={'join_code': game.join_code})) response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code}))
response.set_cookie('host_id', game.host_id, max_age=3600) response.set_cookie('participant_id', participant.participant_id, max_age=3600)
return response return response
except QivipQuiz.DoesNotExist: except QuizGame.DoesNotExist:
return HttpResponseNotFound("Quiz nicht gefunden") # TODO: Fehlermeldung fuer nicht-existierendes Quiz
pass
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
def join_game(request): def join_game(request):
if request.method == 'POST': if request.method == 'POST':
join_code = request.POST.get('game_code') join_code = request.POST.get('game_code')
try: try:
quiz = QuizGame.objects.get(join_code=join_code) 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) return redirect('play:lobby', join_code=join_code)
except QuizGame.DoesNotExist: except QuizGame.DoesNotExist:
messages.error(request, "Dieses Spiel existiert nicht.") # TODO: Mit message eine Fehlermeldung weitergeben (und im entsprechenden Template Designen)
return render(request, 'play/join_game.html') pass
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

@@ -50,75 +50,4 @@ a.qp-a-button-small {
.input-group form p button { .input-group form p button {
@apply p-3 rounded-full bg-indigo-500 hover:bg-indigo-600 text-white focus:scale-105 @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; 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;
} }

View File

@@ -9,15 +9,11 @@
<h1 class="text-center m-4 text-3xl lg:text-6xl">Willkommen, <b>{{ request.user.username }}</b>!</h1> <h1 class="text-center m-4 text-3xl lg:text-6xl">Willkommen, <b>{{ request.user.username }}</b>!</h1>
<form action="{% url 'accounts:logout' %}" method="post"> <form action="{% url 'accounts:logout' %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200 cursor-pointer" type="submit">Abmelden</button> <button class="bg-blue-600 text-white p-3 rounded-full font-black" type="submit">Abmelden</button>
</form> </form>
<button class="text-center text-sm w-full mt-4 font-light text-blue-600"><a href="{% url 'accounts:password_change' %}">Passwort ändern</a></button>
<button class="text-center text-sm w-full mt-4 font-light text-red-600"><a href="{% url 'accounts:delete_account' %}">Account löschen</a></button>
</div> </div>
<div class="flex justify-end"> <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 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> </span>

View File

@@ -24,10 +24,9 @@
<div class="items-center w-full"> <div class="items-center w-full">
<input class=" w-full p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black" type="password" name="password" id="password" required placeholder="PASSWORT"> <input class=" w-full p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black" type="password" name="password" id="password" required placeholder="PASSWORT">
</div> </div>
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200 cursor-pointer" type="submit" class="login-button">Anmelden</button> <button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit" class="login-button">Anmelden</button>
</form> </form>
<button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:register' %}" class="register_link">Noch kein Konto?</a></button> <button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:register' %}" class="register_link">Noch kein Konto?</a></button>
<button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:password_reset' %}" class="register_link">Passwort vergessen?</a></button>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -37,14 +37,7 @@
{{ form.password2 }} {{ form.password2 }}
</div> </div>
<div class="register">
{{ form.email }}
</div>
<div class="text-center text-gray-600 text-sm dislay-flex align-bottom">
<label class=''>Mit der Erstellung ihres Kontos stimmen Sie der <a class="text-blue-600" href="/privacy">Datenschutzerklärung </a> zu.</label>
</div>
<button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit" class="login-button">Registrieren</button> <button class="w-full p-3 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200" type="submit" class="login-button">Registrieren</button>
</form> </form>
<button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:login' %}" class="register_link">Bereits ein Konto?</a></button> <button class="text-center text-sm w-full mt-2 font-light text-blue-600"><a href="{% url 'accounts:login' %}" class="register_link">Bereits ein Konto?</a></button>

View File

@@ -6,74 +6,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{% static 'css/t-style.css' %}"> <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> <title>qivip</title>
</head> </head>
<body> <body>
{% include 'partials/_nav.html' %} {% include 'partials/_nav.html' %}
{% block content%}{% endblock %} {% block content%}{% endblock %}
{% block faqs%}{% endblock %}
{% include 'partials/_footer.html' %} {% 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 %} {% block extra_js %}{% endblock %}
<style>html {
overflow-y: scroll;
}</style>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,22 @@
{% extends 'components/base.html' %}
{% load static %}
{% block title %}Antwort{% endblock %}
{% block content %}
<link rel="stylesheet" href="{% static 'homepage/t-style.css' %}">
<!-- TODO Bedingung festlegen: Quiztyp unterscheiden % if <Bedingung> % -->
{% if user.is_authenticated %}
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-red-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">1</button>
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-indigo-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">2</button>
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-yellow-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">3</button>
<button class="overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-green-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">4</button>
<!-- TODO wieder einfügen, wenn Bedingung gestetzt -->
{% else %}
<button class=" overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-green-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">wahr</button>
<button class=" overflow-x-auto text-[clamp(0.3rem,5vw,1rem)] break-words border-4 border-gray-500 text-2xl font-black m-0.75 bg-red-500 rounded-sm shadow-[0_2px_2px_rgba(0,0,0,0.1)] text-center text-white">falsch</button>
<!--TODO wieder einfügen, wenn Bedingung gestetzt plus -->
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% load static %}
<!doctype html>
<html class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body class="dark:bg-gray-900 h-full m-0">
<div style="height: calc(12vh);" class=" m-0 text-white bg-blue-600 rounded-lg h-12 shadow-md font-semibold px-1 w-full h-20 shadow-[0_2px_2px_rgba(0,0,0,0.1)] break-words text-center flex items-center justify-center overflow-x-auto text-[clamp(0.55rem,5vw,1.5rem)]" > Frage: Wie hoch ist der Eiffelturm insgesamt?
</div>
<div class=" dark:bg-gray-900 flex justify-end gap-2 " style="height: calc(12vh);">
<span class="mt-4 text-[clamp(0.6rem,5vw,1rem)] overflow-x-auto ml-0.75 text-white bg-blue-400 mb-6 h-12 rounded-sm p-3 shadow-[0_2px_2px_rgba(0,0,0,0.1)] z-40 items-center">Zeit: 60s</span>
<div class="mt-4 text-[clamp(0.6rem,5vw,1rem)] overflow-x-auto mr-0.75 bg-gray-500 text-white h-12 mb-6 rounded-sm p-3 shadow-[0_2px_2px_rgba(0,0,0,0.1)] z-40 items-center ">Deine Punkte: 60</div>
</div>
<div class="mt-3 flex justify-center -z-1 " style="height: calc(37vh);"> <img class="object-cover " src="{% static 'components/Ausgabe.png' %}" alt="Bild"> </div>
<div class="w-full grid grid-cols-2 p-2 h-full" style="height: calc(38vh);min-height: 50px;">
{% block content %}
{% endblock %}
</div>
<div class="absolute bottom-0 right-0">
<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>
 </body>
</html>

View File

@@ -0,0 +1,63 @@
{% load static %}
<link rel="stylesheet" href="{% static 'homepage/t-style.css' %}">
<!doctype html>
<html class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body class=" dark:bg-gray-900 w-full justify-center h-full m-0">
<div style="height: calc(10vh);" class=" m-0 text-white bg-blue-600 rounded-lg h-12 shadow-md font-semibold px-1 w-full h-20 shadow-[0_2px_2px_rgba(0,0,0,0.1)] break-words text-center flex items-center justify-center overflow-x-auto text-[clamp(0.75rem,5vw,1.5rem)]" >Ranking
</div>
<div class="m-4 flex justify-end font-bold">Frage 1/10</div>
<div class="text-[clamp(0.75rem,5vw,1.25rem)] font-bold flex items-center justify-around border-blue-600 border-4 space-x-2 mt-2 overflow-x-auto rounded-lg p-4 shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div style="background:linear-gradient(120deg, rgb(255, 208, 0),rgb(216, 151, 21))" class="text-white !important px-4 rounded-sm">1. Platz</div>
<div class="text-stone-900 ">6000 Punkte</div>
<div class="">Person XY</div>
</div>
<div class=" text-[clamp(0.75rem,5vw,1.25rem)] font-bold p-4 flex items-center justify-around border-blue-600 border-4 space-x-2 mt-2 overflow-x-auto rounded-lg shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div style="background:linear-gradient(120deg, rgb(102, 122, 122),rgb(194, 186, 186))" class="text-white !important px-4 rounded-sm">2. Platz</div>
<div class="text-stone-900 ">5000 Punkte</div>
<div class="">Person XY</div>
</div>
<div class=" text-[clamp(0.75rem,5vw,1.25rem)] font-bold flex items-center justify-around border-blue-600 border-4 space-x-2 mt-2 overflow-x-auto rounded-lg p-4 shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div style="background:linear-gradient(120deg, rgb(211, 91, 17),rgb(219, 168, 109))" class="text-white !important px-4 rounded-sm">3. Platz</div>
<div class="text-stone-900 ">600 Punkte</div>
<div class="">Person XY</div>
</div>
<div class=" text-[clamp(0.75rem,5vw,1.25rem)] items-center flex justify-around font-bold border-blue-600 border-4 space-x-2 mt-2 text-[clamp(0.6rem,5vw,1rem)] overflow-x-auto rounded-lg p-4 shadow-[0_2px_2px_rgba(0,0,0,0.1)] w-full">
<div>4. Platz</div>
<div>200 Punkte</div>
<div>Person XY</div>
</div>
<div class="absolute bottom-0 right-0">
<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>
 </body>
</html>

View File

@@ -4,164 +4,12 @@
<div class="grid place-content-center md:h-screen h-150 w-full -z-50 top-0 p-4"> <div class="grid place-content-center md:h-screen h-150 w-full -z-50 top-0 p-4">
<div class="text-center"> <div class="text-center">
<h2 class="font-bold md:text-8xl text-6xl md:mb-8 text-blue-600">qivip</h2> <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-4xl text-md">forever and forall</h3> <h3 class="italic font-extralight sm:text-2xl text-md">Interaktives Lernen neu definiert.</h3>
</div> </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"> <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-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-indigo-500 text-white" href="#">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-purple-500 text-white" href="{% url 'library:new_quiz' %}">Erstellen</a>
</div> </div>
</div> </div>
{% endblock %}
{% block faqs %}
<style>
details[open] summary svg {
transform: rotate(180deg);
}
</style>
<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 class="text-2xl font-bold text-gray-900">FAQs</h2>
<div class="h-0.5 flex-grow bg-gradient-to-r from-blue-600 to-transparent rounded-full"></div>
</div>
</div>
<div class="input-group !max-w-4xl tracking-wide">
<h1 class="font-black text-blue-600 flex justify-between gap-2 lg:text-lg">
FAQs - häufig gestellte Fragen
<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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg></h1>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Was ist qivip?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">qivip ist eine Webseite zum Lernen! Es können vor allem Quizze erstellt, bearbeitet, kopiert und auch gespielt werden. </p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Was sind die Vorteile von qivip?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">
Einige Vorteile von qivip:
<br>
- Sie können Quizze moderieren ohne Account (siehe wie man Quizze mit mehreren Personen gemeinsam spielt)
<br>
- alle öffentlichen Quizze können auch ohne Account gespielt werden
<br>
- qivip ist 100% kostenlos
<br>
- es gibt keine Werbung
<br>
- qivip ist Open Source
</p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Ist qivip kostenlos?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">Ja! qivip ist zu 100% kostenlos und es gibt keine versteckten Kosten.</p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Muss ich mich bei qivip registrieren, wenn ich ein Quiz spielen möchte?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">Nein! qivip ist so ausgelegt, dass man auch ohne Account alle öffentlichen Quizze spielen kann.</p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Kann ich ein Quiz von qivip auch alleine spielen?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">Ja! Gehen Sie zur Bibliothek und klicken Sie auf den Button "spielen" (eines von Ihnen ausgewählten Quizzes). Wählen Sie anschließend bei den Spielmodi einfach den Lernmodus aus.</p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Wie kann ich ein Quiz erstellen?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">Wichtig: Für diese Funktion müssen Sie angemeldet sein. Gehen Sie dafür auf die Startseite und klicken Sie auf "Erstellen". Nun wird Ihnen ein Formular angezeigt, dass Sie dann ausfüllen.
Anschließend können Sie Fragen zu dem Quiz hinzufügen, indem Sie auf den Button eines Fragetyps klicken. </p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Wie kann ich mit mehreren Personen ein Quiz gemeinsam spielen?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">
Wenn man ein Quiz mit mehreren Personen spielt, gibt es sowohl einen Moderator als auch die Teilnehmer.
<br>
<br>
<b>Moderator: </b>Der Moderator selbst spielt nicht mit. Gehen Sie entweder auf die Startseite und klicken Sie auf "Moderieren" oder klicken einfach auf "spielen" (eines von Ihnen ausgewählten Quizzes) und dann auf Moderiermodus.
Der Moderator kann das Spiel, wenn die Teilnehmer da sind starten.
<br>
<br>
<b>Teilnehmer: </b>Gehen Sie auf die Startseite und klicken Sie auf "Teilnehmen" und geben Sie dann den beim Moderator angegebenen Zahlencode ein. Anschließlich können Sie noch einen Anzeigenamen eingeben.
</p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Wo steht unser Server?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">Unser Server befindet sich in Deutschland, genauer gesagt in Nürnberg. </p>
</details>
</div>
<div class="input-group mt-2.5 !max-w-4xl hover:!bg-gray-50">
<details>
<summary class="flex justify-between cursor-pointer font-bold">Wer steckt hinter qivip?
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<p class="text-blue-600">Wir sind eine kleine Gruppe von Schülern, die sich im Rahmen eines Enrichmenten Projektes zusammengefunden hat. </p>
</details>
</div>
{% endblock %} {% endblock %}

View File

@@ -5,7 +5,7 @@
<div class="input-group p-4 m-2 border-blue-600 border-2 rounded-xl shadow-md"> <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"> <div class="grid place-content-center h-120">
<h1 class="text-center m-4 text-3xl lg:text-6xl">Datenschutz-</br>erklärung</h1> <h1 class="text-center m-4 text-3xl break-words lg:text-6xl">Datenschutz-<br>erklärung</b></h1>
Hier muss die Datenschutzerklärung stehen! <br> Hier muss die Datenschutzerklärung stehen! <br>
<br> <br>

View File

@@ -1,115 +1,32 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<div class="container mx-auto px-4"> <div class="container mx-auto px-4">
<div class="flex justify-between items-center mb-6">
<div class="flex flex-wrap space-y-2 items-center justify-between mb-6"> <h1 class="text-2xl font-bold">{{ quiz.name }}</h1>
<h1 class="text-2xl font-bold mr-4 ">{{ quiz.name }}</h1> <div class="flex space-x-2">
<a href="{% url 'library:edit_quiz' quiz.id %}" class=" text-white px-4 py-2 rounded-md text-sm lg:text-lg hover:scale-110 transition-colors">&#x270F</a>
<div class=" flex flex-wrap space-x-2 "> <a href="{% url 'library:delete_quiz' quiz.id %}" class=" text-white px-4 py-2 rounded-md text-sm lg:text-lg hover:scale-110 transition-colors">&#x1F5D1</a>
<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> </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> </div>
{% if quiz.description %} {% if quiz.description %}
<p class="text-gray-600 mb-4">{{ quiz.description }}</p> <p class="text-gray-600 mb-8">{{ quiz.description }}</p>
{% endif %} {% 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="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="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> <h2 class="text-xl font-semibold">Fragen</h2>
<div class="flex space-x-2 flex-wrap"> <div class="flex space-x-2">
{% if quiz.user_id == request.user %}
<a href="{% url 'library:new_question' %}?type=input&quiz_id={{ quiz.id }}"
class=" mt-1 bg-green-100 text-green-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
Eingabe
</a>
{% endif %}
{% if quiz.user_id == request.user %}
<a href="{% url 'library:new_question' %}?type=multiple_choice&quiz_id={{ quiz.id }}" <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 Multiple Choice
</a> </a>
{% endif %}
{% if quiz.user_id == request.user %}
<a href="{% url 'library:new_question' %}?type=true_false&quiz_id={{ quiz.id }}" <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 Wahr/Falsch
</a> </a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -120,77 +37,60 @@
{% for question in questions %} {% for question in questions %}
<div class="w-full rounded-xl p-4 hover:bg-gray-50"> <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 justify-between items-start">
<div class="flex-grow"> <div class="flex-grow">
<p class="font-medium">{{ question.data.question }}</p> <p class="font-medium">{{ question.data.question }}</p>
<div class="mt-1"> <div class="mt-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {% if question.data.type == 'multiple_choice' %}bg-blue-100 text-blue-800 {% elif question.data.type == 'input' %} bg-green-100 text-green-800 {% else %}bg-purple-100 text-purple-800{% endif %}"> <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {% if question.data.type == 'multiple_choice' %}bg-blue-100 text-blue-800{% else %}bg-purple-100 text-purple-800{% endif %}">
{% if question.data.type == 'multiple_choice' %}Multiple Choice{% elif question.data.type == 'input' %}Eingabe{% else %}Wahr/Falsch{% endif %} {% if question.data.type == 'multiple_choice' %}Multiple Choice{% else %}Wahr/Falsch{% endif %}
</span> </span>
</div> </div>
<div class="mt-2"> <div class="mt-2">
{% if detail %}
{% if question.data.type == 'multiple_choice' %} {% if question.data.type == 'multiple_choice' %}
<ul class="space-y-1 "> <ul class="space-y-1 ">
{% for option in question.data.options %} {% for option in question.data.options %}
<li class="flex items-center text-sm"> <li class="flex items-center text-sm">
{% if option.is_correct %} {% 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"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg> </svg>
<span class="font-medium text-green-700">{{ option.value }}</span> <span class="font-medium text-green-700">{{ option.value }}</span>
{% else %} {% else %}
<svg class="h-4 w-4 text-gray-400 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/>
</svg> </svg>
<span class="text-gray-600">{{ option.value }}</span> <span class="text-gray-600">{{ option.value }}</span>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% elif question.data.type == 'input' %} {% else %}
<ul class="space-y-1 "> {% for option in question.data.options %}
{% for option in question.data.options %} {% if option.is_correct %}
<li class="flex items-center text-sm"> <p class="text-sm">
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="text-gray-500">Richtige Antwort:</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/> <span class="ml-1 font-medium {% if option.value == "Wahr" %} text-green-700 {% else %}text-red-700{% endif %}">{{ option.value }}</span>
</svg> </p>
<span class="font-medium text-green-700">{{ option.value }}</span> {% endif %}
</li> {% endfor %}
{% 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 %} {% endif %}
{% endfor %}
{% endif %}
{% endif %}
</div> </div>
</div> </div>
<div class="flex h-full space-x-2 ml-4 items-center"> <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 %}" <a href=" {% url 'library:delete_question' question.id %}"
class=" text-red-700 px-4 py-1 rounded hover:scale-110 "> class=" text-red-700 px-4 py-1 rounded hover:scale-110 ">
{% if quiz.user_id == request.user %} &#x1F5D1
<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"> </a>
<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>
</div> </div>
</div> </div>
@@ -210,32 +110,5 @@
</div> </div>
{% endif %} {% endif %}
</div> </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>
<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 %} {% endblock %}

View File

@@ -2,36 +2,10 @@
{% block content %} {% block content %}
<div class="input-group border-blue-600 border-2 rounded-xl shadow-md mt-12"> <div class="input-group border-blue-600 border-2 rounded-xl shadow-md mt-12">
<form method="post" enctype="multipart/form-data"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
<button type="submit">Speichern</button>
{% 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="text-center text-gray-600 text-sm dislay-flex align-bottom">
<label class=''>Mit der Erstellung von diesem Quiz stimmen Sie der <a class="text-blue-600" href="/privacy">Datenschutzerklärung </a> zu.</label>
</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>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,671 +1,61 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %}
{% block extra_css %}
<style>
.custom-outline {
-webkit-text-stroke: 0.2px rgb(0, 0, 0);
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="flex justify-end mr-2">
<a href="{% url 'library:new_quiz' %}" class="mt-2 p-2 shadow-md border-blue-600 border-2 rounded-md text-black mb-12">Neues Quiz erstellen</a>
</div>
{% if quizzes %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4 place-items-center">
<h1 class="font-bold mb-4 px-4 bg-blue-100">meine eigenen Quiz</h1></div>
<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 h-80 ">
<h2 class="text-lg font-bold break-words truncate"><a href="{% url 'library:edit_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
<p class="text-sm text-gray-600 pt-11 h-54"><span class="break-words line-clamp-2">{{ quiz.description }} </span><br> Status: <span class="font-bold">{{ quiz.status }}</span>
<br> Schwierigkeit:<span class="font-bold"> {{ quiz.difficulty }}</span>
<br> Anzahl der Fragen:<span class="font-bold"> {{ quiz.question.count }}</span> </p>
<div class="container mx-auto px-4 py-4 flex justify-between items-center"> <div>
<button id="filterButton" class="p-2 hover:bg-gray-100 rounded-lg transition-colors duration-200 flex items-center gap-2 text-gray-700"> <div class="flex justify-between items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <a href="#" class="qp-a-button-small border-2 border-blue-600 text-black">Spiel starten</a>
<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> <div class="flex gap-2">
<span>Filter</span> <a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white">&#x270F;</a>
</button> <a href="{% url 'library:delete_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white">&#x1F5D1;</a>
<div class="flex gap-2"> </div>
<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"> </div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Zurücksetzen
</a>
<a href="{% url 'library:new_quiz' %}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neues Quiz
</a>
</div> </div>
</div>
<div id="filterPanel" class="container mx-auto px-4"> {% endfor %}
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200"> {% endif %}
<form action="{% url 'library:overview_quiz' %}" method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6"> </div>
<input type="hidden" name="filter" value="1">
<div class="space-y-2"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4 place-items-center">
<label class="block text-sm font-medium text-gray-700">minimale Anzahl an Fragen</label> <h1 class="font-bold px-4 bg-blue-100 mt-8 mb-4">Quiz von anderen Nutzern</h1></div>
<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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 px-4 place-items-center">
{% for quiz in all_quizzes %}
<div class="h-80 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 break-words truncate">{{ quiz.name }}</h2>
<p class="text-sm text-gray-600 pt-11 h-54"><span class="break-words line-clamp-2">{{ quiz.description }} </span> <br> Schwierigkeit:<span class="font-bold ">
{{ quiz.difficulty }}</span><br> Anzahl der Fragen:<span class="font-bold"> {{ quiz.question.count }}</span>
<br>Erstmalig erstellt am:<span class="font-bold"> {{ quiz.creation_date }}</span>
</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">
<div class="qp-a-button-small text-gray-600 text-sm "> Quiz von {{ quiz.user_id }}</div>
</div>
</div>
</div> </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>
</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 %} {% 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> </div>
{% endif %}
{% endblock %}
{% 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 %}

View File

@@ -1,93 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mx-auto px-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{% if question %}Frage bearbeiten{% else %}Neue Eingabe Frage{% endif %}</h1>
<a href="{% url 'library:detail_quiz' quiz.id %}"
class="bg-gray-100 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-200 transition-colors">
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">
{% csrf_token %}
<!-- Question Text -->
<div>
<label for="question" class="block text-sm font-medium text-gray-700">Frage</label>
<div class="mt-1">
<textarea name="question" id="question" rows="4"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
required>{{ question.data.question }}</textarea>
</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>
<!-- Eingabe -->
{% if question and question.data.options %}
{% for option in question.data.options %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label>
<div class="flex space-x-4">
<textarea name="correct_answer" rows="2"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 flex-grow sm:text-sm border-gray-300 rounded-md"
required>{{ option.value }}</textarea>
</div>
</div>
{% endfor %}
{% else %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label>
<div class="flex space-x-4">
<textarea name="correct_answer" rows="2"
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 flex-grow sm:text-sm border-gray-300 rounded-md"
required></textarea>
</div>
</div>
{% endif %}
<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">
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">
{% if question %}Speichern{% else %}Erstellen{% endif %}
</button>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -9,8 +9,9 @@
Zurück zum Quiz Zurück zum Quiz
</a> </a>
</div> </div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md"> <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"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<!-- Question Text --> <!-- Question Text -->
@@ -23,19 +24,6 @@
</div> </div>
</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 --> <!-- Options -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Antwortmöglichkeiten</label> <label class="block text-sm font-medium text-gray-700 mb-2">Antwortmöglichkeiten</label>
@@ -104,19 +92,6 @@
</button> </button>
</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"> <div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}" <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"> 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

@@ -11,7 +11,7 @@
</div> </div>
<div class="bg-white rounded-lg shadow-md p-6 border-blue-600 border-2 rounded-xl shadow-md"> <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"> <form method="post" class="space-y-6">
{% csrf_token %} {% csrf_token %}
<!-- Question Text --> <!-- Question Text -->
@@ -24,18 +24,6 @@
</div> </div>
</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 --> <!-- True/False Selection -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label> <label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label>
@@ -55,19 +43,6 @@
</div> </div>
</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"> <div class="flex justify-end space-x-3">
<a href="{% url 'library:detail_quiz' quiz.id %}" <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"> 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,193 +1,15 @@
<nav id="main-nav" class="relative bg-blue-600 rounded-lg mt-1 m-2 shadow-md transition-all duration-300"> <nav class="flex justify-between bg-blue-600 h-12 px-4 sm:px-6 lg:px-8 rounded-lg m-2 shadow-md">
<!-- Desktop Navigation Bar --> <div class="flex items-center">
<div class="flex justify-between items-center h-12 px-4 sm:px-6 overflow-x-auto whitespace-nowrap"> <h2 class="text-xl font-bold text-white hover:scale-110 transition duration-200"><a href="{% url 'homepage:home' %}">qivip</a></h2>
<!-- 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 id="quiz-search-desktop" list="quiz-names-desktop" 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">
<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>
</div> </div>
<div class="flex items-center">
<!-- Mobile Navigation Menu - Hidden by default - FULLWIDTH DROPDOWN --> <ul class="flex space-x-4 qp-nav-list">
<div id="mobile-menu" class="hidden absolute w-full z-50 md:hidden opacity-0 transform -translate-y-2 transition-all duration-300"> <li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
<div class="bg-blue-500 pt-3 pb-4 rounded-b-lg shadow-inner"> {% if user.is_authenticated %}
<!-- Search in mobile menu --> <li><a href="{% url 'accounts:home' %}">Konto</a></li>
<form method="get" action="{% url 'library:overview_quiz' %}" class="px-4 mb-4"> {% else %}
<div class="flex items-center justify-center bg-white rounded-full px-4 py-2 shadow-md"> <li><a href="{% url 'accounts:login' %}">Anmelden</a></li>
<input id="quiz-search-mobile" list="quiz-names-mobile" type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}" {% endif %}
class="w-full text-sm px-2 text-black bg-transparent focus:outline-none"> </ul>
<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>
<datalist id="quiz-names-desktop">
{% for quiz in quiz_names %}
<option value="{{ quiz.name }}">
{% endfor %}
</datalist>
<datalist id="quiz-names-mobile">
{% for quiz in quiz_names %}
<button value="{{ quiz.name }}" onclick="performSearch(this.value)"><option value="{{ quiz.name }}" ></option></button>
{% endfor %}
</datalist>
{{ quiz_names|json_script:"quiz-names-data" }}
<script>
async function fetchQuizNames() {
try {
const response = await fetch("{% url 'library:quiz_names_json' %}");
if (!response.ok) throw new Error("Netzwerkfehler");
const data = await response.json();
if (Array.isArray(data)) return data;
} catch (err) {
console.error("Fehler beim Laden der Quiz-Namen:", err);
}
return [];
}
function setupAutocomplete(inputId, datalistId, quizNames) {
const searchInput = document.getElementById(inputId);
const datalist = document.getElementById(datalistId);
if (!searchInput || !datalist) return;
// Wenn der Benutzer den Text ändert, Filter anwenden
searchInput.addEventListener('input', () => {
const value = searchInput.value.toLowerCase();
datalist.innerHTML = '';
quizNames
.filter(name => name.toLowerCase().includes(value))
.slice(0, 10)
.forEach(name => {
const option = document.createElement('option');
option.value = name;
datalist.appendChild(option);
});
});
}
// Funktion zum Durchführen der Suche
function performSearch(query) {
datalist.innerHTML = '';
// Hier kann deine Logik zur Verarbeitung der Suche eingefügt werden
// Falls du die Seite zu einer Suchergebnisseite weiterleiten möchtest:
window.location.href = `{% url 'library:overview_quiz' %}?search=${query}`;
}
document.addEventListener('DOMContentLoaded', async () => {
const quizNames = await fetchQuizNames();
setupAutocomplete('quiz-search-desktop', 'quiz-names-desktop', quizNames);
setupAutocomplete('quiz-search-mobile', 'quiz-names-mobile', quizNames);
});
</script>
</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> </div>
</nav> </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,242 +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>
{% if question_data.type != "input" %}
<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>
{% else %}
{% if request.session.mode == "learn" %}
<form action="">
<input id="textanswer" type="text" placeholder="DEINE ANTWORT"
class="answer-option text-center p-2 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option lg:w-80 ">
<button {% if request.session.mode == "learn" %}
onclick="submitAnswer( -1 );" {% endif %}
class="mt-2 text-center p-2 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option">
abschicken
</button>
</form>
{% endif %}
{% endif %}
</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'
}));
{% if request.session.mode != "learn" %}
updateTimer();
{% endif %}
};
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;
let input = ""; // Declare input here
try {
input = document.getElementById("textanswer").value;
} catch (e) {
input = "";
}
hasAnswered = true;
const now = Date.now();
let timeRemaining = Math.max(0, questionDuration - (now - questionStartTime));
{% if request.session.mode == "learn" %}
timeRemaining = questionDuration;
{% endif %}
// 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');
}
}
if(input!=""){
// Send answer to server
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer:input,
time_remaining: timeRemaining
}));
}
else{
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer:optionIndex,
time_remaining: timeRemaining
}));
}
}
</script>
{% endblock %}

View File

@@ -1,145 +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>
{% if question_data.type != "input" %}
<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>
{% else %}
<form action="">
<input id="textanswer" type="text" placeholder="DEINE ANTWORT"
class="answer-option text-center p-2 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option lg:w-80">
<button
onclick="submitAnswer( -1 );"
class="mt-2 text-center p-2 bg-gray-100 hover:bg-blue-100 rounded-lg transition-colors duration-200 answer-option">
abschicken
</button>
</form>
{% endif %}
</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;
let input = ""; // Declare input here
try {
input = document.getElementById("textanswer").value;
} catch (e) {
input = "";
}
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');
}
}
if(input!=""){
// Send answer to server
gameSocket.send(JSON.stringify({
type: 'submit_answer',
participant_id: participantId,
answer:input,
time_remaining: timeRemaining
}));
}
else{
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,211 +1,5 @@
{% extends 'base.html' %} <!-->
Spieler sehen ihre Punktzahl, Platzierung und Richtig/Falsch
{% block content %} Host: Scoreboard, Button: 'Weiter'
<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 id="option-box-{{ forloop.counter0 }}" class="bg-gray-100 relative p-4 rounded-lg overflow-hidden {% if option.is_correct %} border-2 border-green-500{% else %}border-2 border-red-500{% endif %}">
<div id="option-fill-{{ forloop.counter0 }}" class="absolute left-0 top-0 h-full {% if option.is_correct %}bg-green-300 {% else %}bg-red-300 {% endif %} opacity-50 z-0 transition-all duration-1500 " style="width: 0%;"></div>
<div class="relative z-10">
<p class="text-lg">{{ option.value }}</p>
<p class="text-gray-600" id="option-count-{{ forloop.counter0 }}">0 Antworten</p>
</div>
</div>
{% endfor %}
</div>
</div>
<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 data-last-score="{{ participant.last_score }}" class="text-gray-600" id="score-{{ forloop.counter0 }}">{{ 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>
{% 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) {
const total = Object.values(stats).reduce((a, b) => a + b, 0) || 1;
for (var optionIndex in stats) {
if (stats.hasOwnProperty(optionIndex)) {
var countElement = document.getElementById('option-count-' + optionIndex);
var fillElement = document.getElementById('option-fill-' + optionIndex);
if (countElement) {
countElement.textContent = stats[optionIndex] + ' Antwort(en)';
}
if (fillElement) {
const percentage = Math.round((stats[optionIndex] / total) * 100);
fillElement.style.width = percentage + '%';
}
}
}
}
// Request answer stats when page loads
gameSocket.onopen = function(e) {
gameSocket.send(JSON.stringify({
'type': 'get_answer_stats'
}));
};
</script>
{% endblock %}

View File

@@ -1,53 +1,5 @@
{% extends 'base.html' %} <!-->
Spieler: Countdown, automatische Weiterleitung
{% block content %} (Host: Frage)
<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 +1,13 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %}
{% block content %} {% block content %}
<div class="container mx-auto px-4 py-8"> <h1>User for a Game:</h1>
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6"> <div class="input-group border-blue-600 border-2 rounded-xl shadow-md">
<h1 class="text-2xl font-bold mb-6 text-center text-blue-600">Spieler einrichten</h1> <form method="post">
{% csrf_token %}
<div class="text-center mb-6"> <input type="hidden" name="join_code" value="{{join_code}}">
<div class="inline-block bg-blue-100 rounded-lg px-4 py-2"> <input type="text" name="display_name" placeholder="Anzeigename">
<span class="text-sm text-blue-800">Spiel-Code:</span> <button type="submit">Speichern</button>
<span class="font-mono font-bold text-blue-900">{{ join_code }}</span> </form>
</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> </div>
<style>
input:focus {
outline: none;
}
.container {
min-height: calc(100vh - 4rem);
display: flex;
align-items: center;
}
</style>
{% endblock %} {% endblock %}

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