Compare commits
161 Commits
60-refacto
...
118-zuruck
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9199678b4b | ||
|
|
5d8f3ed820 | ||
| a5d4e75040 | |||
| 7e4bb05c74 | |||
| 1bb2666784 | |||
|
|
c05c3fd4a0 | ||
|
|
c9a1342f1d | ||
|
|
991488790a | ||
|
|
c014003062 | ||
|
|
8133eaa439 | ||
| 38a2ffcaff | |||
|
|
cfa95a84e7 | ||
|
|
64e89d3749 | ||
|
|
2708be3177 | ||
|
|
647b45d8a6 | ||
|
|
c8e2817b07 | ||
|
|
01e7909e3a | ||
| dfee602d54 | |||
|
|
0062f88167 | ||
|
|
de3d3dd00c | ||
|
|
85ba8bc9cb | ||
|
|
a8103b4a20 | ||
|
|
ba3e89f9f7 | ||
|
|
dbc457b21d | ||
|
|
a6cac52ba6 | ||
|
|
5f10c2a57b | ||
|
|
19092b092d | ||
|
|
2ca623bf33 | ||
|
|
a7c42ecf13 | ||
|
|
cc8cc9ad74 | ||
|
|
0c8a8d3062 | ||
|
|
a021e7561a | ||
|
|
9321b44056 | ||
|
|
873b50103d | ||
|
|
41cecf98dc | ||
|
|
ac5859a666 | ||
|
|
779762c2a3 | ||
|
|
da78060415 | ||
|
|
b823909386 | ||
|
|
82f0cd5191 | ||
|
|
1069dd1cad | ||
|
|
905da6737c | ||
|
|
6eed8107ac | ||
|
|
95e4428943 | ||
|
|
bad257f341 | ||
|
|
eaf709ed4d | ||
|
|
8ca470ad54 | ||
|
|
af919b9ea5 | ||
|
|
154110e510 | ||
|
|
a16c97c4f3 | ||
|
|
01562c6ad8 | ||
|
|
50cd95daf1 | ||
|
|
1a4b9e0d6b | ||
|
|
867c839202 | ||
|
|
8d8e211c72 | ||
|
|
6da095c35e | ||
|
|
2dc9a077ee | ||
|
|
881bc3d6c1 | ||
|
|
b1e438229a | ||
|
|
97e4fa9505 | ||
|
|
3981dc31da | ||
|
|
7b86b0791c | ||
|
|
7faf931fb2 | ||
|
|
9245f928dc | ||
|
|
1f5a177060 | ||
|
|
ebbe4f7193 | ||
|
|
acd07bfb80 | ||
|
|
d824816be8 | ||
|
|
2c563b3471 | ||
|
|
4122f0631b | ||
|
|
2372cf7146 | ||
|
|
6305af9879 | ||
|
|
176a3cc15c | ||
|
|
fa9bb45d3f | ||
|
|
cc449997dc | ||
|
|
e813660722 | ||
|
|
1d6fab919e | ||
|
|
17195a221f | ||
|
|
3fc08cbd4e | ||
|
|
4cf6f45daf | ||
|
|
d2ad0f041f | ||
|
|
bcdfebf791 | ||
|
|
937fd4a2e4 | ||
|
|
a9343f285a | ||
|
|
d4252c1297 | ||
|
|
2052a702f0 | ||
|
|
a0292ce409 | ||
|
|
73cd52ea3d | ||
|
|
ffe636c6c7 | ||
|
|
e22ea40910 | ||
|
|
e269264f03 | ||
|
|
9fb9ab9198 | ||
|
|
4209dcbc2d | ||
|
|
70be6396aa | ||
|
|
5b4c76a1c8 | ||
|
|
9d9e1527f4 | ||
|
|
70741a5a34 | ||
|
|
8022602fc5 | ||
|
|
f0dcc2baad | ||
|
|
2221c99ad7 | ||
|
|
d84b44252e | ||
|
|
5a719de8ee | ||
|
|
7c8c142e34 | ||
|
|
093b0a7f83 | ||
|
|
a4442fe12e | ||
|
|
a59df8dd62 | ||
|
|
80390de6dc | ||
|
|
5f05964b1b | ||
|
|
d53240c710 | ||
|
|
1f3b551b94 | ||
|
|
2a7e39bbd0 | ||
|
|
04ce8104fe | ||
|
|
7ce0e6e9dd | ||
|
|
b0782868b8 | ||
|
|
a39c944f10 | ||
|
|
655e2a2e5d | ||
|
|
087d4cdddd | ||
|
|
77714d22a9 | ||
|
|
f405016f96 | ||
|
|
145d339ae1 | ||
|
|
4e6de6fe85 | ||
|
|
c4489c2301 | ||
|
|
bb60197c46 | ||
|
|
a89847f818 | ||
|
|
071e76b3af | ||
|
|
295c3aa6d5 | ||
|
|
2fab7683e1 | ||
|
|
f264d10cb8 | ||
|
|
95f2a82532 | ||
|
|
d9f3d07dd7 | ||
|
|
cf7a2fb8bb | ||
|
|
3f07ce4ec7 | ||
|
|
3637edce33 | ||
|
|
cd2e138f66 | ||
|
|
0062b1f9c9 | ||
|
|
a3ac9c474a | ||
|
|
783ae44ce1 | ||
|
|
0068e162f7 | ||
|
|
b0b4724b4a | ||
|
|
ad74ec8f29 | ||
|
|
00109d4694 | ||
|
|
0a91d70cd3 | ||
|
|
1cd6d66b85 | ||
|
|
3ef1abf35e | ||
|
|
e29b954438 | ||
|
|
8c3b365e0b | ||
|
|
33b0e256c4 | ||
|
|
21713498b2 | ||
|
|
8d1489f513 | ||
|
|
cbd02bece8 | ||
|
|
e135ff945f | ||
|
|
f3591bb9da | ||
|
|
fbb0787aa9 | ||
|
|
16fcaf6346 | ||
|
|
8a6d337392 | ||
|
|
5f4fd4345e | ||
|
|
7e93165c58 | ||
|
|
419160d144 | ||
|
|
0c6580e732 | ||
|
|
db71ea2a5b | ||
|
|
d2375e136a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ t-style.css
|
||||
dump.rdb
|
||||
media
|
||||
node_modules
|
||||
staticfiles
|
||||
69
README.md
69
README.md
@@ -7,6 +7,7 @@
|
||||
3. [Superuser erstellen](#superuser-erstellen)
|
||||
4. [Tailwind-Nutzung](#tailwind-nutzung)
|
||||
5. [Issue - Merge Request - Merge](#issue-mergerequest-merge)
|
||||
6. [Konfiguration](#konfiguration)
|
||||
|
||||
## Initialisierung des Projekts
|
||||
|
||||
@@ -47,17 +48,63 @@ Um das Projekt erfolgreich zu starten, folge diesen Schritten:
|
||||
|
||||
### Entwicklungsserver starten
|
||||
|
||||
Um den Server zu starten, verwende einen der folgenden Befehle:
|
||||
Für die Entwicklung mit WebSocket-Unterstützung verwenden wir Daphne:
|
||||
|
||||
- Standardport (8000):
|
||||
1. Aktiviere die virtuelle Umgebung:
|
||||
```sh
|
||||
source .venv/bin/activate # Linux
|
||||
.venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
2. Starte den Daphne-Server:
|
||||
```sh
|
||||
# Im Verzeichnis 'django'
|
||||
daphne -b 127.0.0.1 -p 8000 core.asgi:application
|
||||
```
|
||||
|
||||
3. Starte den REDIS Server
|
||||
```sh
|
||||
redis-server
|
||||
```
|
||||
> **Hinweis:** Der Redis Server muss, wenn er nicht auf dem selben Gerät unter Standardeinstellungen läuft, in der Datei play/consumers/lobby.py und core/settings.py entsprechend umkonfiguriert werden!
|
||||
|
||||
|
||||
Alternativ kannst du den Django-Entwicklungsserver verwenden, wenn du keine WebSocket-Funktionalität benötigst:
|
||||
```sh
|
||||
python3 core/manage.py runserver
|
||||
```
|
||||
- Bestimmten Port (z. B. 9000):
|
||||
|
||||
### 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 runserver 9000
|
||||
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!
|
||||
|
||||
## Superuser erstellen
|
||||
@@ -100,10 +147,10 @@ npx @tailwindcss/cli -i <INPUT-DATEI> -o <OUTPUT-DATEI> --watch --minify
|
||||
|
||||
```sh
|
||||
# unter Linux mit Tailwind Binärdatei:
|
||||
tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
|
||||
npx tailwindcss -i static/css/t-input.css -o static/css/t-style.css --watch --minify
|
||||
|
||||
# unter Windows mit NPM (in bash mit '/' statt '\'):
|
||||
npx @tailwindcss/cli -i .\static\css\t-input.css -o .\static\css\t-style.css --watch --minify
|
||||
npx @tailwindcss/cli -i ./static/css/t-input.css -o ./static/css/t-style.css --watch --minify
|
||||
```
|
||||
|
||||
## Issue - Merge Request - Merge
|
||||
@@ -112,6 +159,14 @@ Hier der vereinbarte Arbeitsablauf:
|
||||
|
||||
1. Issue erstellen - das geht am einfachsten über die Webseite. Der Titel beschreibt kurz und klar, was angestrebt wird.
|
||||
|
||||
2. Merge Request anlegen - dadurch wird der Bransch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden.
|
||||
2. Merge Request anlegen - dadurch wird der Branch automatisch angelegt. Dieser kann mit `git pull` geholt und mit `git checkout <branch>` bearbeitet werden.
|
||||
|
||||
3. Nach dem Hochladen kann der Merge in den Master durchgeführt werden.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Die Konfiguration des Projekts befindet sich im `config/qivip_config.json`.
|
||||
|
||||
| Variable | Bedeutung | Optionen |
|
||||
| --- | --- | --- |
|
||||
| ENABLE_RATING_SYSTEM | Soll das Bewertungssystem aktiviert werden? | true [Standard], false |
|
||||
7
datenschutzerklaerung.txt
Normal file
7
datenschutzerklaerung.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Name und Anschrift des Verantwortlichen
|
||||
|
||||
//NAME HIER EINSETZEN//
|
||||
|
||||
//ADRESSE HIER EINSETZEN//
|
||||
|
||||
//E-MAIL HIER EINSETZEN//
|
||||
@@ -6,11 +6,15 @@ from django import forms
|
||||
class RegisterForm(UserCreationForm):
|
||||
class Meta:
|
||||
model=User
|
||||
fields = ['username','password1','password2']
|
||||
fields = ['username','password1','password2', "email"]
|
||||
|
||||
username = forms.CharField(
|
||||
widget=forms.TextInput(attrs={'placeholder': 'BENUTZERNAME'})
|
||||
)
|
||||
email = forms.EmailField(
|
||||
widget=forms.TextInput(attrs={'placeholder': 'E-MAIL'})
|
||||
)
|
||||
|
||||
password1 = forms.CharField(
|
||||
widget=forms.PasswordInput(attrs={'placeholder': 'PASSWORT'})
|
||||
)
|
||||
@@ -19,4 +23,3 @@ class RegisterForm(UserCreationForm):
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.urls import path # type: ignore
|
||||
from django.urls import path, reverse_lazy # type: ignore
|
||||
from . import views
|
||||
from django.contrib.auth.views import LogoutView
|
||||
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
|
||||
#
|
||||
@@ -13,4 +14,22 @@ urlpatterns = [
|
||||
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('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'
|
||||
),
|
||||
|
||||
]
|
||||
@@ -2,12 +2,20 @@ from django.shortcuts import render, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth import login, authenticate
|
||||
from .forms import RegisterForm
|
||||
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.
|
||||
@login_required
|
||||
def home(request):
|
||||
return render(request, 'accounts/home.html')
|
||||
|
||||
|
||||
|
||||
def register(response):
|
||||
if response.method == "POST":
|
||||
form = RegisterForm(response.POST)
|
||||
@@ -19,3 +27,33 @@ def register(response):
|
||||
form = RegisterForm()
|
||||
|
||||
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')
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ComponentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'components'
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,14 +0,0 @@
|
||||
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')
|
||||
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
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}) #
|
||||
3
django/config/qivip_config.json
Normal file
3
django/config/qivip_config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ENABLE_RATING_SYSTEM": true
|
||||
}
|
||||
@@ -8,9 +8,21 @@ https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
django.setup()
|
||||
|
||||
application = get_asgi_application()
|
||||
from play.routing import websocket_urlpatterns
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
'http': get_asgi_application(),
|
||||
'websocket': AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
websocket_urlpatterns
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
@@ -10,11 +10,13 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.1/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
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
|
||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||
@@ -27,13 +29,15 @@ DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '*']
|
||||
|
||||
# Konfigurationsdatei für Projekt
|
||||
with open(BASE_DIR / 'config' / 'qivip_config.json') as config_file:
|
||||
QIVIP_CONFIG = json.load(config_file)
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'library.apps.LibraryConfig',
|
||||
'homepage.apps.HomepageConfig',
|
||||
'components.apps.ComponentsConfig',
|
||||
'accounts.apps.AccountsConfig',
|
||||
'play',
|
||||
'django.contrib.admin',
|
||||
@@ -42,10 +46,13 @@ INSTALLED_APPS = [
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'channels',
|
||||
'django_cleanup',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -73,6 +80,17 @@ TEMPLATES = [
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'core.wsgi.application'
|
||||
ASGI_APPLICATION = 'core.asgi.application'
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [("127.0.0.1", 6379)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Database
|
||||
@@ -85,6 +103,15 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
|
||||
@@ -121,8 +148,15 @@ USE_TZ = True
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
|
||||
# WhiteNoise configuration
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
|
||||
WHITENOISE_SKIP_COMPRESS_EXTENSIONS = ['css', 'js']
|
||||
# Return 404 instead of 500 for missing files
|
||||
WHITENOISE_MISSING_FILE_ERRNO = None
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf.urls.static import static
|
||||
from django.conf import settings
|
||||
from django.contrib import admin # type: ignore
|
||||
from django.urls import path, include
|
||||
|
||||
@@ -23,5 +25,7 @@ urlpatterns = [
|
||||
path('', include('homepage.urls')),
|
||||
path('play/', include('play.urls')),
|
||||
path('library/', include('library.urls')),
|
||||
path('components/', include('components.urls'))
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
@@ -1,15 +1,14 @@
|
||||
from django.contrib import admin
|
||||
from .models import QivipQuiz, QivipQuestion, QuizCategory, Tag
|
||||
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizCategory, QuizImage
|
||||
|
||||
# Für das QivipQuiz Modell
|
||||
class QivipQuizAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user_id', 'status', 'category', 'creation_date', 'update_date') # Welche Felder sollen in der Übersicht angezeigt werden
|
||||
search_fields = ('name', 'user_id__username', 'category__name') # Suchfelder
|
||||
list_filter = ('status', 'category') # Filteroptionen
|
||||
|
||||
# Für das QivipQuestion Modell
|
||||
class QivipQuestionAdmin(admin.ModelAdmin):
|
||||
list_display = ('quiz_id', 'data', 'creation_date', 'update_date')
|
||||
list_display = ('id', 'quiz_id', 'data', 'creation_date', 'update_date')
|
||||
search_fields = ('data',)
|
||||
list_filter = ('quiz_id',)
|
||||
|
||||
@@ -19,13 +18,22 @@ class QuizCategoryAdmin(admin.ModelAdmin):
|
||||
search_fields = ('name',)
|
||||
prepopulated_fields = {'slug': ('name',)} # Erstelle automatisch den Slug basierend auf dem Namen
|
||||
|
||||
# Für das Tag Modell
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
list_display = ('name',) # Zeige nur den Namen des Tags
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
class QivipQuizFavoriteAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'favorite', 'quiz') # Welche Felder sollen in der Übersicht angezeigt werden
|
||||
|
||||
|
||||
class QuizImageAdmin(admin.ModelAdmin):
|
||||
list_display = ('image',)
|
||||
|
||||
class QuestionImageAdmin(admin.ModelAdmin):
|
||||
list_display = ('image',)
|
||||
|
||||
# Registrierung der Modelle im Admin
|
||||
admin.site.register(QivipQuiz, QivipQuizAdmin)
|
||||
admin.site.register(QivipQuestion, QivipQuestionAdmin)
|
||||
admin.site.register(QuizCategory, QuizCategoryAdmin)
|
||||
admin.site.register(Tag, TagAdmin)
|
||||
admin.site.register(QivipQuizFavorite, QivipQuizFavoriteAdmin)
|
||||
admin.site.register(QuizImage, QuizImageAdmin)
|
||||
admin.site.register(QuestionImage, QuestionImageAdmin)
|
||||
@@ -4,18 +4,18 @@ from .models import QivipQuiz, QivipQuestion
|
||||
class QuizForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = QivipQuiz
|
||||
fields = ['name', 'description','image', 'status', 'category', 'tags',"difficulty"]
|
||||
fields = ['name', 'description', 'status', 'category','difficulty','credits']
|
||||
|
||||
class QuestionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = QivipQuestion
|
||||
fields = ['quiz_id', 'data']
|
||||
fields = ['quiz_id', 'data','time_per_question']
|
||||
|
||||
from django import forms
|
||||
|
||||
class QuizFilterForm(forms.Form):
|
||||
search = forms.CharField(required=False, label="Suche", widget=forms.TextInput(attrs={
|
||||
'class': 'flex flex-grow rounded-lg p-2' ,'placeholder': 'Suche ...',
|
||||
'class': 'flex flex-grow rounded-lg p-2' ,'placeholder': 'Suche ...', 'list': 'quiz-names',
|
||||
}))
|
||||
|
||||
|
||||
|
||||
13
django/library/management/commands/cleanup_games.py
Normal file
13
django/library/management/commands/cleanup_games.py
Normal file
@@ -0,0 +1,13 @@
|
||||
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.')
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
21
django/library/migrations/0027_qivipquiz_creator.py
Normal file
21
django/library/migrations/0027_qivipquiz_creator.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
21
django/library/migrations/0028_alter_qivipquiz_creator.py
Normal file
21
django/library/migrations/0028_alter_qivipquiz_creator.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
18
django/library/migrations/0029_qivipquiz_credits.py
Normal file
18
django/library/migrations/0029_qivipquiz_credits.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
18
django/library/migrations/0034_alter_qivipquiz_status.py
Normal file
18
django/library/migrations/0034_alter_qivipquiz_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
18
django/library/migrations/0035_qivipquiz_favorite.py
Normal file
18
django/library/migrations/0035_qivipquiz_favorite.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
19
django/library/migrations/0042_qivipquiz_favorite.py
Normal file
19
django/library/migrations/0042_qivipquiz_favorite.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
17
django/library/migrations/0043_remove_qivipquiz_favorite.py
Normal file
17
django/library/migrations/0043_remove_qivipquiz_favorite.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
14
django/library/migrations/0047_merge_20250418_1334.py
Normal file
14
django/library/migrations/0047_merge_20250418_1334.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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 = [
|
||||
]
|
||||
18
django/library/migrations/0048_qivipquestion_image.py
Normal file
18
django/library/migrations/0048_qivipquestion_image.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
18
django/library/migrations/0049_alter_qivipquestion_image.py
Normal file
18
django/library/migrations/0049_alter_qivipquestion_image.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -4,10 +4,19 @@ from django.utils.text import slugify
|
||||
import uuid
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class QuizImage(models.Model):
|
||||
image = models.ImageField(max_length=256,verbose_name="Bild", upload_to='quiz_images/', blank=True, null=True) # Bild speichern in media/quiz_images/
|
||||
|
||||
|
||||
class QuestionImage(models.Model):
|
||||
image = models.ImageField(verbose_name="Bild", upload_to="question_images/", blank=True, null=True)
|
||||
|
||||
|
||||
class QivipQuiz(models.Model):
|
||||
STATUS_VALUES = {
|
||||
"öffentlich": "Öffentlich",
|
||||
"versteckt": "Versteckt",
|
||||
#"versteckt": "Versteckt",
|
||||
"privat": "Privat",
|
||||
}
|
||||
DIFFICULTY_VALUES = {
|
||||
@@ -20,42 +29,102 @@ class QivipQuiz(models.Model):
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def validate_description(value):
|
||||
if value.strip() == "In dem Quiz geht es um ...":
|
||||
raise ValidationError("Bitte gib eine aussagekräftige Beschreibung ein.")
|
||||
|
||||
image = models.ImageField(max_length=256, upload_to='quiz_images/', blank=True, null=True) # Bild speichern in media/quiz_images/
|
||||
image = models.ForeignKey(
|
||||
QuizImage,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="quizzes"
|
||||
)
|
||||
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
user_id = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quiz')
|
||||
#creator = models.ForeignKey(User, on_delete=models.SET_NULL,blank=True, null=True, related_name='creator_quiz')
|
||||
base_quiz = models.ForeignKey('self', on_delete=models.SET_NULL,blank=True, null=True, related_name='child_quizzes')
|
||||
creation_date = models.DateTimeField(auto_now_add=True)
|
||||
update_date = models.DateTimeField(auto_now=True)
|
||||
status = models.CharField(max_length=10, choices=list(STATUS_VALUES.items()), default="öffentlich")
|
||||
category = models.ForeignKey('QuizCategory', related_name='quiz', on_delete=models.CASCADE)
|
||||
tags = models.ManyToManyField('Tag', blank=True)
|
||||
name = models.CharField(max_length=75)
|
||||
description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...")
|
||||
difficulty= models.CharField(max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit")
|
||||
|
||||
category = models.ForeignKey('QuizCategory',verbose_name="Kategorie", related_name='quiz', on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=75,verbose_name="Name des Quizzes")
|
||||
description = models.TextField(validators=[validate_description],max_length=200,blank=False, default="In dem Quiz geht es um ...", verbose_name="Beschreibung")
|
||||
difficulty= models.CharField(verbose_name="Schwierigkeit", max_length=13, choices=list(DIFFICULTY_VALUES.items()), default="nicht gesetzt", help_text="1: niedrigste Schwierigkeit und 5: höchste Schwierigkeit")
|
||||
credits=models.TextField(blank=True, editable=True)
|
||||
average_rating = models.FloatField(default=3.0)
|
||||
rating_count = models.IntegerField(default=0)
|
||||
def __str__(self):
|
||||
|
||||
return self.name
|
||||
|
||||
class QivipQuizFavorite(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE) # Verknüpfung zu User
|
||||
quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='favorites') # Verknüpfung zu Quiz
|
||||
favorite = models.BooleanField(default=False) # Favoritenstatus
|
||||
favorite_date = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'quiz') # Sicherstellen, dass es nur ein Favorit pro User und Quiz gibt
|
||||
|
||||
|
||||
# Create your models here.
|
||||
class QivipQuestion(models.Model):
|
||||
|
||||
TIME_VALUES = {
|
||||
"10": 10,
|
||||
"15": 15,
|
||||
"30": 30,
|
||||
"60": 60,
|
||||
"90": 90,
|
||||
"120": 120,
|
||||
"180": 180,
|
||||
}
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='question')
|
||||
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='questions')
|
||||
creation_date = models.DateTimeField(auto_now_add=True)
|
||||
update_date = models.DateTimeField(auto_now=True)
|
||||
data = models.TextField()
|
||||
time_per_question = models.CharField(max_length=3, choices=list(TIME_VALUES.items()), default=30, help_text="Zeit pro Frage in Sekunden")
|
||||
image = models.ForeignKey(
|
||||
QuestionImage,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="questions"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.data[:50]
|
||||
|
||||
class Tag(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class QuizRating(models.Model):
|
||||
quiz = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE, related_name='ratings')
|
||||
participant_id = models.CharField(max_length=200)
|
||||
rating = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)])
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('quiz', 'participant_id')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if is_new:
|
||||
# Update quiz average rating
|
||||
quiz = self.quiz
|
||||
total_ratings = quiz.ratings.count()
|
||||
avg_rating = quiz.ratings.aggregate(models.Avg('rating'))['rating__avg']
|
||||
quiz.average_rating = round(avg_rating, 1) if avg_rating else 3.0
|
||||
quiz.rating_count = total_ratings
|
||||
quiz.save()
|
||||
|
||||
class QuizCategory(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
|
||||
@@ -14,6 +14,8 @@ urlpatterns = [
|
||||
path('question/new/', views.new_question, name='new_question'),
|
||||
path('question/edit/<int:pk>/', views.edit_question, name='edit_question'),
|
||||
path('question/delete/<int:pk>/', views.delete_question, name='delete_question'),
|
||||
]# Nur für die Entwicklungsumgebung
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
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"),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.contrib import messages
|
||||
from .models import QivipQuiz, QivipQuestion
|
||||
from .models import QivipQuiz, QivipQuestion, QivipQuizFavorite, QuestionImage, QuizImage
|
||||
from .forms import QuizForm, QuestionForm
|
||||
import json
|
||||
from django.core.paginator import Paginator
|
||||
@@ -13,108 +16,184 @@ from django.db.models import Count
|
||||
from django.contrib.auth.models import User
|
||||
# Übersicht aller Quizze
|
||||
|
||||
def overview_quiz(request):
|
||||
|
||||
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):
|
||||
# Filter
|
||||
form = QuizFilterForm(request.GET)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('question'))
|
||||
favorite_quizzes_data = QivipQuizFavorite.objects.filter(user=request.user, favorite=True).order_by("-favorite_date")
|
||||
favorite_quiz_ids = favorite_quizzes_data.values_list('quiz', flat=True)
|
||||
favorite_quizzes = QivipQuiz.objects.filter(id__in=favorite_quiz_ids)
|
||||
except:
|
||||
filter_my_quizzes = QivipQuiz.objects.none()
|
||||
favorite_quizzes_data = QivipQuizFavorite.objects.none()
|
||||
favorite_quizzes = QivipQuizFavorite.objects.none()
|
||||
favorite_quiz_ids = favorite_quizzes.none()
|
||||
favorite_quizzes = QivipQuiz.objects.none()
|
||||
|
||||
# Initialize querysets
|
||||
if request.user.is_authenticated:
|
||||
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user).annotate(max_amout_questions=Count('questions'))
|
||||
favorite_quizzes = favorite_quizzes.filter(id__in=favorite_quiz_ids).annotate(max_amout_questions=Count('questions'))
|
||||
filter_other_quizzes = QivipQuiz.objects.exclude(user_id=request.user)
|
||||
else:
|
||||
favorite_quizzes = QivipQuiz.objects.none()
|
||||
filter_my_quizzes = QivipQuiz.objects.none()
|
||||
filter_other_quizzes = QivipQuiz.objects.all()
|
||||
|
||||
# Apply common filters to other quizzes
|
||||
filter_other_quizzes = filter_other_quizzes.annotate(max_amout_questions=Count('questions'))
|
||||
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=1, status='öffentlich')
|
||||
|
||||
filter_other_quizzes = QivipQuiz.objects.annotate(max_amout_questions=Count('question'))
|
||||
|
||||
|
||||
|
||||
|
||||
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')
|
||||
if search: # Suche nach Namen
|
||||
filter_other_quizzes = filter_other_quizzes.filter(name__icontains=search)
|
||||
filter_my_quizzes = filter_my_quizzes.filter(name__icontains=search)
|
||||
|
||||
if min_amout_questions:
|
||||
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__gte=min_amout_questions)
|
||||
try:
|
||||
filter_my_quizzes =filter_my_quizzes.filter(max_amout_questions__gte=min_amout_questions)
|
||||
except:
|
||||
filter_my_quizzes = QivipQuiz.objects.none()
|
||||
|
||||
if max_amout_questions:
|
||||
filter_other_quizzes = filter_other_quizzes.filter(max_amout_questions__lte=max_amout_questions)
|
||||
try:
|
||||
filter_my_quizzes =filter_my_quizzes.filter(max_amout_questions__lte=max_amout_questions)
|
||||
except:
|
||||
filter_my_quizzes = QivipQuiz.objects.none()
|
||||
|
||||
filters = {}
|
||||
if username:
|
||||
try:
|
||||
user = User.objects.get(username=username) # Benutzer anhand des Namens holen
|
||||
filter_other_quizzes = filter_other_quizzes.filter(user_id=user.id)
|
||||
filter_my_quizzes = filter_my_quizzes.filter(user_id=user.id) # Nach der user_id filtern
|
||||
user = User.objects.get(username=username)
|
||||
filters['user_id'] = user.id
|
||||
except User.DoesNotExist:
|
||||
filter_other_quizzes = QivipQuiz.objects.none() # Falls User nicht existiert, leere Query zurückgeben
|
||||
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:
|
||||
filter_other_quizzes=filter_other_quizzes.exclude(user_id=request.user)
|
||||
favorite_quizzes = favorite_quizzes.filter(**filters)
|
||||
except:
|
||||
filter_other_quizzes=filter_other_quizzes
|
||||
|
||||
# Anzahl der Quiz pro Seite für Swiper
|
||||
favorite_quizzes=QivipQuiz.objects.none()
|
||||
|
||||
|
||||
context = {
|
||||
'show_search': True,
|
||||
'filter_my_quizzes': filter_my_quizzes,
|
||||
'filter_other_quizzes': filter_other_quizzes,
|
||||
'form': form,
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
return render(request, 'library/overview_quiz.html', context)
|
||||
|
||||
# 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:
|
||||
filter_my_quizzes = QivipQuiz.objects.filter(user_id=request.user)
|
||||
filter_other_quizzes = QivipQuiz.objects.filter(status='öffentlich').exclude(user_id=request.user).filter(question__isnull=False).distinct()
|
||||
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,
|
||||
'filter_my_quizzes': filter_my_quizzes,
|
||||
'filter_other_quizzes': filter_other_quizzes,
|
||||
'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)
|
||||
|
||||
except:
|
||||
filter_other_quizzes = QivipQuiz.objects.filter(status='öffentlich').filter(question__isnull=False).distinct()
|
||||
context = {
|
||||
'show_search': True,
|
||||
'filter_my_quizzes': None,
|
||||
'filter_other_quizzes': filter_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
|
||||
@login_required
|
||||
def new_quiz(request):
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
|
||||
form = QuizForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
quiz = form.save(commit=False)
|
||||
try:
|
||||
quiz_image=request.FILES['quiz_image']
|
||||
imagequiz=QuizImage.objects.create(image=quiz_image)
|
||||
quiz.image=imagequiz
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
quiz.user_id = request.user
|
||||
#quiz.creator = request.user
|
||||
quiz.save()
|
||||
form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
|
||||
#form.save_m2m() # Speichert die Many-to-Many Beziehungen (Tags)
|
||||
return redirect('library:detail_quiz', pk=quiz.pk)
|
||||
|
||||
else:
|
||||
@@ -128,8 +207,31 @@ def edit_quiz(request, pk):
|
||||
if request.method == 'POST':
|
||||
form = QuizForm(request.POST, instance=quiz, files=request.FILES)
|
||||
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()
|
||||
return redirect('library:detail_quiz', pk=quiz.pk)
|
||||
for img in QuizImage.objects.all():
|
||||
try:
|
||||
img.delete()
|
||||
img.image.delete(save=False)
|
||||
except:
|
||||
pass
|
||||
return redirect('library:detail_quiz', pk=pk)
|
||||
#return modified(request, pk)
|
||||
|
||||
else:
|
||||
form = QuizForm(instance=quiz)
|
||||
return render(request, 'library/form.html', {'form': form})
|
||||
@@ -140,15 +242,28 @@ def delete_quiz(request, pk):
|
||||
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
|
||||
if request.method == 'POST':
|
||||
quiz.delete()
|
||||
for img in QuizImage.objects.all():
|
||||
try:
|
||||
img.delete()
|
||||
img.image.delete(save=False)
|
||||
except:
|
||||
pass
|
||||
for img in QuestionImage.objects.all():
|
||||
try:
|
||||
img.delete()
|
||||
img.image.delete(save=False)
|
||||
except:
|
||||
pass
|
||||
|
||||
return redirect('library:overview_quiz')
|
||||
return render(request, 'library/delete_confirmation.html', {'object': quiz})
|
||||
|
||||
# Quiz anzeigen
|
||||
@login_required
|
||||
def detail_quiz(request, pk):
|
||||
quiz = get_object_or_404(QivipQuiz, pk=pk, user_id=request.user)
|
||||
#quiz = get_object_or_404(QivipQuiz, pk=pk, 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)
|
||||
|
||||
# Parse JSON data for each question
|
||||
for question in questions:
|
||||
if question.data:
|
||||
@@ -156,10 +271,46 @@ def detail_quiz(request, pk):
|
||||
|
||||
context = {
|
||||
'quiz': quiz,
|
||||
'questions': questions
|
||||
'questions': questions,
|
||||
'detail':show_answers,
|
||||
}
|
||||
return render(request, 'library/detail_quiz.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def copy_quiz(request, pk):
|
||||
original_quiz = get_object_or_404(QivipQuiz, pk=pk)
|
||||
|
||||
|
||||
|
||||
# Quiz kopieren (ohne ID, damit ein neues Objekt erstellt wird)
|
||||
|
||||
# Quiz kopieren (ohne ID, damit ein neues Objekt erstellt wird)
|
||||
|
||||
new_quiz = original_quiz # Kopie erstellen (aber Achtung: Noch gleiche Referenz!)
|
||||
new_quiz.pk = None # Setzt die ID auf None, damit Django es als neues Objekt erkennt
|
||||
new_quiz.uuid = uuid.uuid4() # Neue UUID generieren
|
||||
new_quiz.user_id = request.user # Neuer Besitzer ist der aktuelle User
|
||||
new_quiz.name += " - Kopie"
|
||||
new_quiz.base_quiz_id = pk
|
||||
new_quiz.rating_count = 0
|
||||
new_quiz.average_rating = 3.0
|
||||
new_quiz.save() # Speichern als neues Objekt
|
||||
# Optional: Beschreibung anpassen
|
||||
# Alle zugehörigen Fragen kopieren
|
||||
questions = QivipQuestion.objects.filter(quiz_id=pk)
|
||||
for question in questions:
|
||||
question.uuid = uuid.uuid4()
|
||||
question.pk = None # Setze pk auf None, damit es als neues Objekt gespeichert wird
|
||||
question.quiz_id = original_quiz # Verknüpfe mit dem neuen Quiz
|
||||
question.data = question.data # `data`-Feld wird explizit übernommen # Verknüpfe mit dem neuen Quiz
|
||||
|
||||
question.save()
|
||||
|
||||
messages.success(request, "Quiz wurde erfolgreich kopiert!")
|
||||
return redirect('library:detail_quiz', pk=new_quiz.pk)
|
||||
|
||||
|
||||
# Übersicht aller Fragen
|
||||
@login_required
|
||||
def question_list(request):
|
||||
@@ -188,7 +339,7 @@ def new_question(request):
|
||||
messages.error(request, 'Quiz nicht gefunden oder keine Berechtigung.')
|
||||
return redirect('library:overview_quiz')
|
||||
|
||||
if question_type not in ['multiple_choice', 'true_false']:
|
||||
if question_type not in ['multiple_choice', 'true_false','input']:
|
||||
base_url = reverse('library:new_question')
|
||||
return redirect(f'{base_url}?type=multiple_choice&quiz_id={quiz_id}')
|
||||
|
||||
@@ -206,6 +357,17 @@ def new_question(request):
|
||||
{'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':
|
||||
options = []
|
||||
i = 1
|
||||
@@ -233,7 +395,17 @@ def new_question(request):
|
||||
question = QivipQuestion()
|
||||
question.data = json.dumps(json_data)
|
||||
question.quiz_id = quiz
|
||||
try:
|
||||
question_image=request.FILES['question_image']
|
||||
imagequestion=QuestionImage.objects.create(image=question_image)
|
||||
question.image=imagequestion
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
question.time_per_question = request.POST.get('time_per_question', '30')
|
||||
question.save()
|
||||
#return modified(request, pk=quiz.pk)
|
||||
return redirect('library:detail_quiz', pk=quiz.pk)
|
||||
else:
|
||||
# Initialize empty question data for new questions
|
||||
@@ -246,6 +418,19 @@ def new_question(request):
|
||||
{'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':
|
||||
question_data = {
|
||||
'type': question_type,
|
||||
@@ -255,12 +440,14 @@ def new_question(request):
|
||||
{'value': '', 'is_correct': False}
|
||||
]
|
||||
}
|
||||
standard_time_per_question = 30
|
||||
|
||||
template_name = f'library/question/question_{question_type}.html'
|
||||
context = {
|
||||
'question': question_data,
|
||||
'quiz_id': quiz_id,
|
||||
'quiz': quiz
|
||||
'quiz': quiz,
|
||||
'standard_time_per_question': standard_time_per_question,
|
||||
}
|
||||
|
||||
return render(request, template_name, context)
|
||||
@@ -269,6 +456,8 @@ def new_question(request):
|
||||
@login_required
|
||||
def edit_question(request, pk):
|
||||
question = get_object_or_404(QivipQuestion, pk=pk, quiz_id__user_id=request.user)
|
||||
if 'reset_ques_image' in request.POST:
|
||||
question.image = None
|
||||
if question.quiz_id.user_id != request.user:
|
||||
return redirect('library:question_list')
|
||||
|
||||
@@ -276,6 +465,7 @@ def edit_question(request, pk):
|
||||
try:
|
||||
question_data = json.loads(question.data) if question.data else {}
|
||||
question_type = question_data.get('type', 'multiple_choice')
|
||||
question_image = question.image
|
||||
|
||||
# For true/false questions, get the correct answer from the options
|
||||
if question_type == 'true_false':
|
||||
@@ -320,6 +510,12 @@ def edit_question(request, pk):
|
||||
|
||||
if request.method == 'POST':
|
||||
question_text = request.POST.get('question', '')
|
||||
try:
|
||||
question_image=request.FILES['question_image']
|
||||
imagequestion=QuestionImage.objects.create(image=question_image)
|
||||
question.image=imagequestion
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Handle different question types
|
||||
if question_type == 'true_false':
|
||||
@@ -332,6 +528,17 @@ def edit_question(request, pk):
|
||||
{'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':
|
||||
options = []
|
||||
i = 1
|
||||
@@ -361,18 +568,32 @@ def edit_question(request, pk):
|
||||
}
|
||||
|
||||
question.data = json.dumps(json_data)
|
||||
question.time_per_question = request.POST.get('time_per_question', '30')
|
||||
|
||||
|
||||
question.save()
|
||||
for img in QuestionImage.objects.all():
|
||||
try:
|
||||
img.delete()
|
||||
img.image.delete(save=False)
|
||||
except:
|
||||
pass
|
||||
#return modified(request, question.quiz_id.pk)
|
||||
return redirect('library:detail_quiz', pk=question.quiz_id.pk)
|
||||
|
||||
else:
|
||||
template_name = f'library/question/question_{question_type}.html'
|
||||
context = {
|
||||
'question': {'data': question_data}, # Wrap in data structure expected by template
|
||||
'quiz_id': question.quiz_id.pk,
|
||||
'quiz': question.quiz_id
|
||||
'quiz': question.quiz_id,
|
||||
'time_per_question': question.time_per_question,
|
||||
'image': question_image,
|
||||
}
|
||||
|
||||
return render(request, template_name, context)
|
||||
|
||||
|
||||
# Frage löschen
|
||||
@login_required
|
||||
def delete_question(request, pk):
|
||||
@@ -384,5 +605,11 @@ def delete_question(request, pk):
|
||||
|
||||
if request.method == 'POST':
|
||||
question.delete()
|
||||
for img in QuestionImage.objects.all():
|
||||
try:
|
||||
img.delete()
|
||||
img.image.delete(save=False)
|
||||
except:
|
||||
pass
|
||||
return redirect('library:detail_quiz', pk=question.quiz_id.pk)
|
||||
return render(request, 'library/delete_confirmation.html', {'object': question})
|
||||
|
||||
913
django/package-lock.json
generated
913
django/package-lock.json
generated
@@ -9,26 +9,907 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"swiper": "^11.2.6"
|
||||
"@tailwindcss/cli": "^4.1.5",
|
||||
"tailwindcss": "^4.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/swiper": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-11.2.6.tgz",
|
||||
"integrity": "sha512-8aXpYKtjy3DjcbzZfz+/OX/GhcU5h+looA6PbAzHMZT6ESSycSp9nAjPCenczgJyslV+rUGse64LMGpWE3PX9Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/swiperjs"
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"type": "open_collective",
|
||||
"url": "http://opencollective.com/swiper"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.7.0"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"swiper": "^11.2.6"
|
||||
"@tailwindcss/cli": "^4.1.5",
|
||||
"tailwindcss": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
527
django/play/consumers.py
Normal file
527
django/play/consumers.py
Normal file
@@ -0,0 +1,527 @@
|
||||
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
|
||||
3
django/play/consumers/__init__.py
Normal file
3
django/play/consumers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .game import GameConsumer
|
||||
|
||||
__all__ = ['GameConsumer']
|
||||
412
django/play/consumers/game.py
Normal file
412
django/play/consumers/game.py
Normal file
@@ -0,0 +1,412 @@
|
||||
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})
|
||||
}
|
||||
)
|
||||
235
django/play/consumers/lobby.py
Normal file
235
django/play/consumers/lobby.py
Normal file
@@ -0,0 +1,235 @@
|
||||
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))
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
29
django/play/migrations/0009_quizanswer.py
Normal file
29
django/play/migrations/0009_quizanswer.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.7 on 2025-04-06 17:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('play', '0009_quizanswer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='quizgameparticipant',
|
||||
name='last_heartbeat',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
18
django/play/migrations/0011_quizgame_updated_at.py
Normal file
18
django/play/migrations/0011_quizgame_updated_at.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -6,9 +6,20 @@ import random, string
|
||||
|
||||
# Create your models here.
|
||||
class QuizGame(models.Model):
|
||||
host_user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='host_user')
|
||||
GAME_STATES = [
|
||||
('lobby', 'In Lobby'),
|
||||
('question', 'Frage läuft'),
|
||||
('scores', 'Punkteübersicht'),
|
||||
('finished', 'Beendet')
|
||||
]
|
||||
|
||||
host_id = models.CharField(max_length=200, unique=True)
|
||||
join_code = models.CharField(max_length=6, unique=True, blank=True)
|
||||
quiz_id = models.ForeignKey(QivipQuiz, on_delete=models.CASCADE)
|
||||
current_state = models.CharField(max_length=20, choices=GAME_STATES, default='lobby')
|
||||
current_question_index = models.IntegerField(default=0)
|
||||
question_start_time = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.join_code:
|
||||
@@ -21,12 +32,21 @@ class QuizGame(models.Model):
|
||||
if not QuizGame.objects.filter(join_code=new_code).exists():
|
||||
return new_code
|
||||
|
||||
def generate_unique_id(self):
|
||||
for i in range(10):
|
||||
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
|
||||
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
|
||||
return new_id
|
||||
|
||||
class QuizGameParticipant(models.Model):
|
||||
participant_id = models.CharField(max_length=200, unique=True)
|
||||
display_name = models.CharField(verbose_name="Anzeigename", max_length=15)
|
||||
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participant")
|
||||
quiz_game = models.ForeignKey(QuizGame, on_delete=models.CASCADE, related_name="participants")
|
||||
score = models.IntegerField(verbose_name="Punkte", default=0)
|
||||
avatar = models.CharField(max_length=200, blank=True, default="")
|
||||
last_heartbeat = models.DateTimeField(auto_now_add=True)
|
||||
last_answer = models.IntegerField(null=True, blank=True)
|
||||
last_answer_correct = models.BooleanField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.participant_id:
|
||||
@@ -38,3 +58,20 @@ class QuizGameParticipant(models.Model):
|
||||
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
|
||||
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
|
||||
return new_id
|
||||
|
||||
class QuizAnswer(models.Model):
|
||||
participant = models.ForeignKey(QuizGameParticipant, on_delete=models.CASCADE, related_name='answers')
|
||||
question_index = models.IntegerField()
|
||||
answer_index = models.IntegerField()
|
||||
is_correct = models.BooleanField()
|
||||
score = models.IntegerField()
|
||||
time_remaining = models.IntegerField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ['participant', 'question_index']
|
||||
|
||||
def generate_unique_id(self):
|
||||
for i in range(10):
|
||||
new_id = ''.join(random.choices(string.digits + string.ascii_lowercase, k=60))
|
||||
if not QuizGameParticipant.objects.filter(participant_id=new_id).exists():
|
||||
return new_id
|
||||
8
django/play/routing.py
Normal file
8
django/play/routing.py
Normal file
@@ -0,0 +1,8 @@
|
||||
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()),
|
||||
]
|
||||
@@ -4,7 +4,14 @@ from . import views
|
||||
app_name = 'play'
|
||||
|
||||
urlpatterns = [
|
||||
path('lobby/<str:join_code>', views.lobby, name='lobby'),
|
||||
path('lobby/<str:join_code>/participate', views.create_participant, name='create_participant'),
|
||||
path('game/new/<int:quiz_id>', views.create_game, name='create_game'),
|
||||
path('game/<str:join_code>', views.lobby, name='lobby'),
|
||||
path('game/<str:join_code>/participate', views.create_participant, name='create_participant'),
|
||||
path('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'),
|
||||
]
|
||||
@@ -1,60 +1,338 @@
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import render, redirect, get_object_or_404, reverse
|
||||
from play.models import QuizGame, QuizGameParticipant
|
||||
from library.models import QivipQuiz
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.http import HttpResponseRedirect, HttpResponseNotFound
|
||||
from django.contrib import messages
|
||||
from django.conf import settings
|
||||
|
||||
import json
|
||||
|
||||
|
||||
# Create your views here.
|
||||
def lobby(request, join_code):
|
||||
if not "participant_id" in request.COOKIES:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
participant_id = request.COOKIES['participant_id']
|
||||
|
||||
|
||||
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
|
||||
participant = get_object_or_404(QuizGameParticipant, participant_id=participant_id)
|
||||
|
||||
if not participant.quiz_game.join_code == join_code:
|
||||
participant.quiz_game = quiz_game
|
||||
participant.save()
|
||||
|
||||
context = {
|
||||
'debug': "test",
|
||||
'participant': participant,
|
||||
'quiz': quiz,
|
||||
'quiz_game': quiz_game,
|
||||
}
|
||||
|
||||
|
||||
mode = request.GET.get('mode','default')
|
||||
request.session['mode'] = mode
|
||||
if "host_id" in request.COOKIES:
|
||||
host_id = request.COOKIES['host_id']
|
||||
if host_id == quiz_game.host_id:
|
||||
context['host_id'] = host_id
|
||||
return render(request, 'play/lobby.html', context=context)
|
||||
elif mode=="learn":
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
|
||||
|
||||
|
||||
if not "participant_id" in request.COOKIES:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
participant_id = request.COOKIES['participant_id']
|
||||
try:
|
||||
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||
if not participant.quiz_game.join_code == join_code: # Teilnehmer dem richtigen Spiel hinzufügen
|
||||
participant.quiz_game = quiz_game
|
||||
participant.score = 0 # Reset score when joining new game
|
||||
participant.save()
|
||||
except QuizGameParticipant.DoesNotExist:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
context['participant'] = participant
|
||||
return render(request, 'play/lobby.html', context=context)
|
||||
|
||||
def select_mode(request,join_code):
|
||||
return render(request, 'play/select_mode.html', {'join_code': join_code})
|
||||
|
||||
|
||||
|
||||
def create_participant(request, join_code):
|
||||
if "participant_id" in request.COOKIES: # Umleiten, falls Teilnehmer bereits erstellt
|
||||
mode = request.GET.get('mode','default')
|
||||
request.session['mode'] = mode
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
if mode!="learn":
|
||||
if "participant_id" in request.COOKIES:
|
||||
# Prüfe ob der Teilnehmer noch existiert
|
||||
participant_id = request.COOKIES['participant_id']
|
||||
try:
|
||||
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||
if participant.quiz_game.join_code != join_code:
|
||||
participant.quiz_game = quiz_game
|
||||
participant.score = 0 # Reset score when joining new game
|
||||
participant.save()
|
||||
return redirect('play:lobby', join_code=join_code)
|
||||
except QuizGameParticipant.DoesNotExist:
|
||||
# Cookie löschen wenn Teilnehmer nicht mehr existiert
|
||||
response = HttpResponseRedirect(request.path)
|
||||
response.delete_cookie('participant_id')
|
||||
return response
|
||||
|
||||
if request.method == 'POST':
|
||||
display_name = request.POST.get('display_name')
|
||||
join_code = request.POST.get('join_code')
|
||||
try:
|
||||
quiz = QuizGame.objects.get(join_code=join_code)
|
||||
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
|
||||
participant.quiz_game = quiz_game
|
||||
participant.score = 0 # Initialize score
|
||||
participant.save()
|
||||
|
||||
response = HttpResponseRedirect(reverse('play:lobby', kwargs={'join_code': join_code}))
|
||||
response.set_cookie('participant_id', participant.participant_id, max_age=3600)
|
||||
|
||||
return response
|
||||
except QuizGame.DoesNotExist:
|
||||
# TODO: Fehlermeldung fuer nicht-existierendes Quiz
|
||||
pass
|
||||
|
||||
return render(request, 'play/initialize_participant.html', {'join_code': join_code})
|
||||
else:
|
||||
messages.error(request, "Beitritt nicht möglich.")
|
||||
return render(request, 'play/join_game.html')
|
||||
def create_game(request, quiz_id):
|
||||
|
||||
try:
|
||||
quiz = QivipQuiz.objects.get(id=quiz_id)
|
||||
game = QuizGame()
|
||||
game.quiz_id = quiz
|
||||
game.host_id = game.generate_unique_id()
|
||||
game.save()
|
||||
|
||||
|
||||
response = HttpResponseRedirect(reverse('play:select_mode', kwargs={'join_code': game.join_code}))
|
||||
response.set_cookie('host_id', game.host_id, max_age=3600)
|
||||
|
||||
return response
|
||||
except QivipQuiz.DoesNotExist:
|
||||
return HttpResponseNotFound("Quiz nicht gefunden")
|
||||
|
||||
|
||||
def join_game(request):
|
||||
if request.method == 'POST':
|
||||
join_code = request.POST.get('game_code')
|
||||
try:
|
||||
quiz = QuizGame.objects.get(join_code=join_code)
|
||||
# Redirect to create_participant if no participant cookie exists
|
||||
if not "participant_id" in request.COOKIES:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
return redirect('play:lobby', join_code=join_code)
|
||||
except QuizGame.DoesNotExist:
|
||||
# TODO: Mit message eine Fehlermeldung weitergeben (und im entsprechenden Template Designen)
|
||||
pass
|
||||
messages.error(request, "Dieses Spiel existiert nicht.")
|
||||
return render(request, 'play/join_game.html')
|
||||
|
||||
def finished(request, join_code):
|
||||
try:
|
||||
game = QuizGame.objects.get(join_code=join_code)
|
||||
participant = None
|
||||
participant_id = request.session.get('participant_id')
|
||||
if participant_id:
|
||||
participant = QuizGameParticipant.objects.filter(
|
||||
quiz_game=game,
|
||||
participant_id=participant_id
|
||||
).first()
|
||||
|
||||
context = {
|
||||
'join_code': join_code,
|
||||
'is_host': str(game.host_id) == str(request.session.get('host_id')),
|
||||
'participant_id': participant_id if participant else None,
|
||||
'rating_enabled': settings.QIVIP_CONFIG['ENABLE_RATING_SYSTEM']
|
||||
}
|
||||
return render(request, 'play/game/finished.html', context)
|
||||
except QuizGame.DoesNotExist:
|
||||
return redirect('home')
|
||||
|
||||
def scores(request, join_code):
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
|
||||
# Verify game state
|
||||
if quiz_game.current_state != 'scores':
|
||||
return redirect('play:lobby', join_code=join_code)
|
||||
|
||||
# Get participants sorted by score
|
||||
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
|
||||
|
||||
# Check if user is host
|
||||
is_host = False
|
||||
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
|
||||
is_host = True
|
||||
|
||||
# Get current question for context
|
||||
questions = quiz_game.quiz_id.questions.all()
|
||||
current_question = questions[quiz_game.current_question_index]
|
||||
question_data = json.loads(current_question.data)
|
||||
|
||||
try:
|
||||
my_participant = request.session.get('my_participant-participant_id')
|
||||
participant = QuizGameParticipant.objects.get(participant_id=my_participant)
|
||||
my_participant=participant
|
||||
except:
|
||||
participant = QuizGameParticipant.objects.none()
|
||||
|
||||
return render(request, 'play/game/score_overview.html', {
|
||||
'quiz_game': quiz_game,
|
||||
'participants': participants,
|
||||
'participant': participant,
|
||||
'my_participant':my_participant,
|
||||
'is_host': is_host,
|
||||
'host_id': quiz_game.host_id if is_host else None,
|
||||
'is_last_question': quiz_game.current_question_index + 1 >= len(questions),
|
||||
'question_data': question_data,
|
||||
'question_index': quiz_game.current_question_index,
|
||||
'total_questions': len(questions)
|
||||
})
|
||||
|
||||
def finished(request, join_code):
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
|
||||
# Verify game state
|
||||
if quiz_game.current_state != 'finished':
|
||||
return redirect('play:lobby', join_code=join_code)
|
||||
|
||||
# Get participants sorted by score
|
||||
participants = QuizGameParticipant.objects.filter(quiz_game=quiz_game).order_by('-score')
|
||||
|
||||
# Check if user is host
|
||||
is_host = False
|
||||
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
|
||||
is_host = True
|
||||
|
||||
# Get participant if exists
|
||||
participant_id = request.COOKIES.get('participant_id')
|
||||
participant = None
|
||||
if participant_id:
|
||||
participant = QuizGameParticipant.objects.filter(
|
||||
quiz_game=quiz_game,
|
||||
participant_id=participant_id
|
||||
).first()
|
||||
|
||||
return render(request, 'play/game/finished.html', {
|
||||
'quiz_game': quiz_game,
|
||||
'participants': participants,
|
||||
'winner': participants.first() if participants.exists() else None,
|
||||
'join_code': join_code,
|
||||
'is_host': is_host,
|
||||
'participant_id': participant_id if participant else None
|
||||
})
|
||||
|
||||
def question(request, join_code):
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
|
||||
# Verify game state
|
||||
if quiz_game.current_state != 'question':
|
||||
return redirect('play:lobby', join_code=join_code)
|
||||
|
||||
# Get current question
|
||||
questions = quiz_game.quiz_id.questions.all()
|
||||
if quiz_game.current_question_index >= len(questions):
|
||||
return redirect('play:lobby', join_code=join_code)
|
||||
|
||||
current_question = questions[quiz_game.current_question_index]
|
||||
question_data = json.loads(current_question.data)
|
||||
|
||||
# Check if user is host
|
||||
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
|
||||
return render(request, 'play/game/question_host.html', {
|
||||
'quiz_game': quiz_game,
|
||||
'question_data': question_data,
|
||||
'current_question':current_question,
|
||||
'host_id': quiz_game.host_id,
|
||||
'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
|
||||
'question_index': quiz_game.current_question_index,
|
||||
'total_questions': len(questions),
|
||||
'question_image': current_question.image
|
||||
})
|
||||
|
||||
# Handle participant view
|
||||
if not 'participant_id' in request.COOKIES:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
participant_id = request.COOKIES['participant_id']
|
||||
try:
|
||||
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||
|
||||
|
||||
except QuizGameParticipant.DoesNotExist:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
request.session['my_participant-participant_id'] = participant.participant_id
|
||||
|
||||
return render(request, 'play/game/question_participant.html', {
|
||||
'quiz_game': quiz_game,
|
||||
'question_data': question_data,
|
||||
'current_question':current_question,
|
||||
'participant': participant,
|
||||
'start_time': int(quiz_game.question_start_time.timestamp() * 1000),
|
||||
'question_index': quiz_game.current_question_index,
|
||||
'total_questions': len(questions),
|
||||
})
|
||||
|
||||
def waiting_room(request, join_code):
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
|
||||
# Check if user is host
|
||||
if 'host_id' in request.COOKIES and request.COOKIES['host_id'] == quiz_game.host_id:
|
||||
return redirect('play:question', join_code=join_code)
|
||||
|
||||
# Handle participant view
|
||||
if not 'participant_id' in request.COOKIES:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
participant_id = request.COOKIES['participant_id']
|
||||
try:
|
||||
participant = QuizGameParticipant.objects.get(participant_id=participant_id)
|
||||
except QuizGameParticipant.DoesNotExist:
|
||||
return redirect('play:create_participant', join_code=join_code)
|
||||
|
||||
return render(request, 'play/game/wait_for_other_players.html', {
|
||||
'quiz_game': quiz_game,
|
||||
'participant': participant
|
||||
})
|
||||
|
||||
|
||||
def selected_mode(request, join_code):
|
||||
|
||||
|
||||
mode = request.GET.get('mode','default')
|
||||
request.session['mode'] = mode
|
||||
quiz_game = get_object_or_404(QuizGame, join_code=join_code)
|
||||
quiz = get_object_or_404(QivipQuiz, pk=quiz_game.quiz_id.id)
|
||||
QuizGameParticipant.objects.filter(quiz_game=quiz_game).delete()
|
||||
|
||||
context = {
|
||||
'quiz': quiz,
|
||||
'quiz_game': quiz_game,
|
||||
}
|
||||
if "host_id" in request.COOKIES:
|
||||
host_id = request.COOKIES['host_id']
|
||||
if host_id == quiz_game.host_id:
|
||||
context['host_id'] = host_id
|
||||
|
||||
if mode=="learn":
|
||||
participant = QuizGameParticipant()
|
||||
if request.user.is_authenticated:
|
||||
participant.display_name = request.user.username
|
||||
else:
|
||||
participant.display_name = "SIE"
|
||||
|
||||
participant.participant_id=host_id
|
||||
participant.quiz_game = quiz_game
|
||||
participant.score = 0 # Initialize score
|
||||
participant.save()
|
||||
quiz_game.current_state = "question"
|
||||
quiz_game.question_start_time = timezone.now()
|
||||
quiz_game.save()
|
||||
|
||||
return redirect('play:question',join_code)
|
||||
|
||||
|
||||
return render(request, 'play/lobby.html', context=context)
|
||||
|
||||
@@ -51,3 +51,74 @@ a.qp-a-button-small {
|
||||
@apply p-3 rounded-full bg-indigo-500 hover:bg-indigo-600 text-white focus:scale-105
|
||||
transition duration-200 font-black shadow-md hover:shadow-lg;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
ul.messages li {
|
||||
@apply p-3 rounded-full bg-gray-200 hover:scale-105 transition duration-200 font-black
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100;
|
||||
}
|
||||
|
||||
ul.messages li.error {
|
||||
@apply bg-red-200;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
div.qp-answer-container {
|
||||
@apply grid grid-cols-2 h-screen;
|
||||
}
|
||||
|
||||
div.qp-answer-container div {
|
||||
@apply grid place-content-center rounded-xl m-2 place-self-stretch text-white transition duration-300;
|
||||
@apply nth-1:bg-green-500 hover:nth-1:bg-green-600;
|
||||
@apply nth-2:bg-red-500 hover:nth-2:bg-red-600;
|
||||
@apply nth-3:bg-blue-500 hover:nth-3:bg-blue-600;
|
||||
@apply nth-4:bg-yellow-500 hover:nth-4:bg-yellow-600;
|
||||
@apply nth-5:bg-purple-500 hover:nth-5:bg-purple-600;
|
||||
@apply nth-6:bg-black hover:nth-6:bg-slate-800;
|
||||
}
|
||||
|
||||
div.qp-answer-container div p {
|
||||
@apply font-black text-xl sm:text-2xl md:text-4xl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
div.qp-answer-container-host {
|
||||
@apply grid grid-cols-2 absolute bottom-0 w-screen xl:px-20 xl:pb-10;
|
||||
}
|
||||
|
||||
div.qp-answer-container-host div {
|
||||
@apply grid place-content-center rounded-xl m-1 place-self-stretch text-white transition duration-300;
|
||||
@apply nth-1:bg-green-500 hover:nth-1:bg-green-600;
|
||||
@apply nth-2:bg-red-500 hover:nth-2:bg-red-600;
|
||||
@apply nth-3:bg-blue-500 hover:nth-3:bg-blue-600;
|
||||
@apply nth-4:bg-yellow-500 hover:nth-4:bg-yellow-600;
|
||||
@apply nth-5:bg-purple-500 hover:nth-5:bg-purple-600;
|
||||
@apply nth-6:bg-black hover:nth-6:bg-slate-800;
|
||||
}
|
||||
|
||||
div.qp-answer-container-host div p {
|
||||
@apply font-black text-xl p-4;
|
||||
}
|
||||
|
||||
/* Filter Panel Styles */
|
||||
#filterPanel {
|
||||
@apply hidden transition-all duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
#filterPanel.show {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
div.qp-question-container {
|
||||
@apply text-center text-3xl px-4 py-9 h-screen;
|
||||
}
|
||||
|
||||
div.qp-question-container img {
|
||||
@apply max-w-[50vw] max-h-[40vh] mx-auto;
|
||||
}
|
||||
13
django/static/swiper/swiper-bundle.min.css
vendored
13
django/static/swiper/swiper-bundle.min.css
vendored
File diff suppressed because one or more lines are too long
14
django/static/swiper/swiper-bundle.min.js
vendored
14
django/static/swiper/swiper-bundle.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -9,11 +9,15 @@
|
||||
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
<button class="bg-blue-600 text-white p-3 rounded-full font-black" type="submit">Abmelden</button>
|
||||
<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>
|
||||
</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 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>
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
<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">
|
||||
</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">Anmelden</button>
|
||||
<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>
|
||||
</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:password_reset' %}" class="register_link">Passwort vergessen?</a></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -37,6 +37,11 @@
|
||||
{{ form.password2 }}
|
||||
|
||||
</div>
|
||||
<div class="register">
|
||||
|
||||
{{ form.email }}
|
||||
|
||||
</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>
|
||||
</form>
|
||||
|
||||
@@ -6,12 +6,74 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="{% static 'css/t-style.css' %}">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tsparticles/confetti@3.0.3/tsparticles.confetti.bundle.min.js"></script>
|
||||
<title>qivip</title>
|
||||
</head>
|
||||
<body>
|
||||
{% include 'partials/_nav.html' %}
|
||||
{% block content%}{% endblock %}
|
||||
{% block faqs%}{% endblock %}
|
||||
{% include 'partials/_footer.html' %}
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container" class="fixed top-4 right-4 z-50"></div>
|
||||
|
||||
<!-- Django Messages to Toast -->
|
||||
{% if messages %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% for message in messages %}
|
||||
// Use the existing toast.create function
|
||||
toast.create("{{ message }}", "{{ message.tags }}");
|
||||
{% endfor %}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// WebSocket Utility
|
||||
const wsUtil = {
|
||||
handleClose: function(e) {
|
||||
if (e.code === 1000 || e.code === 1001) {
|
||||
console.log('Socket closed normally');
|
||||
} else {
|
||||
console.error('Socket closed unexpectedly', e.code);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Toast Notification System
|
||||
const toast = {
|
||||
create: function(message, type = 'info', duration = 3000) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `mb-4 p-4 rounded-lg shadow-lg transform transition-all duration-300 ease-in-out
|
||||
${type === 'success' ? 'bg-green-100 border-l-4 border-green-500 text-green-700' :
|
||||
type === 'error' ? 'bg-red-100 border-l-4 border-red-500 text-red-700' :
|
||||
type === 'warning' ? 'bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700' :
|
||||
'bg-blue-100 border-l-4 border-blue-500 text-blue-700'}`;
|
||||
toast.innerHTML = message;
|
||||
|
||||
const container = document.getElementById('toast-container');
|
||||
container.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => toast.classList.add('translate-x-0', 'opacity-100'), 100);
|
||||
toast.classList.add('-translate-x-4', 'opacity-0');
|
||||
|
||||
// Remove after duration
|
||||
setTimeout(() => {
|
||||
toast.classList.add('-translate-x-4', 'opacity-0');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
};
|
||||
if (window.location.pathname !== "/library/") {
|
||||
localStorage.setItem("meine_seite_scroll", 0);}
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
<style>html {
|
||||
overflow-y: scroll;
|
||||
}</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,22 +0,0 @@
|
||||
{% extends 'components/base.html' %}
|
||||
{% load static %}
|
||||
{% block title %}Antwort{% endblock %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{% static 'css/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 %}
|
||||
@@ -1,45 +0,0 @@
|
||||
{% 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>
|
||||
@@ -1,63 +0,0 @@
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/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>
|
||||
@@ -13,3 +13,155 @@
|
||||
</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 %}
|
||||
@@ -1,32 +1,115 @@
|
||||
{% 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">{{ 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">✏</a>
|
||||
<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">🗑</a>
|
||||
|
||||
<div class="flex flex-wrap space-y-2 items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold mr-4 ">{{ quiz.name }}</h1>
|
||||
|
||||
<div class=" flex flex-wrap space-x-2 ">
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
{% if quiz.user_id == request.user %}
|
||||
<a href="{% url 'library:edit_quiz' quiz.id %}" class=" text-white px-2 py-2 rounded-md text-sm lg:text-lg hover:scale-110 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7 text-gray-600">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
|
||||
</svg>
|
||||
|
||||
</a>
|
||||
<a href="{% url 'library:delete_quiz' quiz.id %}" class=" text-white px-2 py-2 rounded-md text-sm lg:text-lg hover:scale-110 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7 text-gray-600">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{% url 'library:copy_quiz' quiz.id %}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75" />
|
||||
</svg>
|
||||
Quiz kopieren
|
||||
</a>
|
||||
|
||||
{% if quiz.questions.count != 0 %}
|
||||
<a href="{% url 'play:create_game' quiz.id %}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Spielen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% if quiz.description %}
|
||||
<p class="text-gray-600 mb-8">{{ quiz.description }}</p>
|
||||
<p class="text-gray-600 mb-4">{{ quiz.description }}</p>
|
||||
{% endif %}
|
||||
<div class="flex justify-end mb-4">
|
||||
{% if detail == True %}
|
||||
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}?show_answers=false">
|
||||
<div class="flex items-center mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
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>
|
||||
Antworten anzeigen
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md border-blue-600 border-2 rounded-xl shadow-md">
|
||||
<div class="border-b border-gray-200 p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center flex-wrap">
|
||||
<h2 class="text-xl font-semibold">Fragen</h2>
|
||||
<div class="flex space-x-2">
|
||||
<div class="flex space-x-2 flex-wrap">
|
||||
{% 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 }}"
|
||||
class="bg-blue-100 text-blue-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
|
||||
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">
|
||||
Multiple Choice
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if quiz.user_id == request.user %}
|
||||
<a href="{% url 'library:new_question' %}?type=true_false&quiz_id={{ quiz.id }}"
|
||||
class="bg-purple-100 text-purple-800 px-4 py-2 rounded-md text-xs lg:text-lg hover:border-blue-600 border-2">
|
||||
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">
|
||||
Wahr/Falsch
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,17 +120,19 @@
|
||||
{% for question in questions %}
|
||||
|
||||
<div class="w-full rounded-xl p-4 hover:bg-gray-50">
|
||||
<a class="block flex h-full w-full" href="{% url 'library:edit_question' question.id %}" >
|
||||
{% if quiz.user_id == request.user %} <a class="block flex h-full w-full" href="{% url 'library:edit_question' question.id %}" > {% endif %}
|
||||
<div class="flex justify-between items-start">
|
||||
|
||||
<div class="flex-grow">
|
||||
<p class="font-medium">{{ question.data.question }}</p>
|
||||
<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{% else %}bg-purple-100 text-purple-800{% endif %}">
|
||||
{% if question.data.type == 'multiple_choice' %}Multiple Choice{% else %}Wahr/Falsch{% 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 {% elif question.data.type == 'input' %} bg-green-100 text-green-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 %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{% if detail %}
|
||||
|
||||
{% if question.data.type == 'multiple_choice' %}
|
||||
<ul class="space-y-1 ">
|
||||
{% for option in question.data.options %}
|
||||
@@ -66,6 +151,18 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% elif question.data.type == 'input' %}
|
||||
<ul class="space-y-1 ">
|
||||
{% for option in question.data.options %}
|
||||
<li class="flex items-center text-sm">
|
||||
<svg class="h-4 w-4 text-green-500 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span class="font-medium text-green-700">{{ option.value }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% else %}
|
||||
{% for option in question.data.options %}
|
||||
{% if option.is_correct %}
|
||||
@@ -75,21 +172,24 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full space-x-2 ml-4 items-center">
|
||||
<!--
|
||||
<a href="{% url 'library:edit_question' question.id %}"
|
||||
class="bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200 transition-colors">
|
||||
Bearbeiten
|
||||
</a>
|
||||
-->
|
||||
|
||||
<a href=" {% url 'library:delete_question' question.id %}"
|
||||
class=" text-red-700 px-4 py-1 rounded hover:scale-110 ">
|
||||
🗑
|
||||
{% if quiz.user_id == request.user %}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-7 text-gray-600">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||
</svg>
|
||||
{% endif %}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
@@ -110,5 +210,31 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{% if quiz.base_quiz %}
|
||||
<p class="text-gray-600 mt-2 ">Bearbeitung:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.user_id }}">{{ quiz.user_id }}</a></span></p>
|
||||
{% else %}
|
||||
<p class="text-gray-600 mt-2 ">Autor:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.user_id }}">{{ quiz.user_id }}</a></span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{% if quiz.base_quiz %}
|
||||
<p class="text-gray-600 ">Kopie von: <a href="{% url 'library:detail_quiz' quiz.base_quiz.id %}"><span class="text-blue-600 font-bold">{{ quiz.base_quiz}}</span></a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
{% if quiz.base_quiz %}
|
||||
<p class="text-gray-600 ">Autor:<span class="text-blue-600 font-bold"> <a href="{% url 'library:overview_quiz' %}?filter=1&user={{ quiz.base_quiz.user_id }}">{{ quiz.base_quiz.user_id }}</a></span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
{% if quiz.credits %}
|
||||
<p class="text-gray-600 mb-2">Credits:<span class="text-blue-600 font-bold"> {{ quiz.credits }}</span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,31 @@
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ 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="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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,195 +1,671 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% load static %}
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Swiper Demo</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="{% static 'swiper/swiper-bundle.min.css' %}">
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
|
||||
|
||||
|
||||
|
||||
.swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.custom-outline {
|
||||
-webkit-text-stroke: 0.2px rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
{% endblock %}
|
||||
|
||||
<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 ">Neues Quiz erstellen</a>
|
||||
</div>
|
||||
<div class="flex justify-end mr-2">
|
||||
<a href="{% url 'library:overview_quiz' %}" class="mt-2 p-2 shadow-md border-blue-600 border-2 rounded-md text-black ">Suche und Filter zurücksetzen</a>
|
||||
</div>
|
||||
{% block content %}
|
||||
|
||||
<div class="w-screen flex justify-center items-center">
|
||||
<div class="w-full m-4 border-blue-600 border-2 rounded-xl shadow-md p-6 lg:w-1/4">
|
||||
<form method="get" class="flex flex-col space-y-4">
|
||||
<h1 class="text-xl font-bold">Filter</h1>
|
||||
Minimale Anzahl an Fragen {{ form.min_amout_questions }}
|
||||
Maximale Anzahl an Fragen {{ form.max_amout_questions }}
|
||||
von User {{ form.user }}
|
||||
<!-- Rendert die Form-Felder -->
|
||||
<button type="submit" class="qp-a-button-small border-2 border-blue-600 text-black">Filtern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Swiper Container für eigene Quiz -->
|
||||
{% if filter_my_quizzes %}
|
||||
<div class="text-center m-8 bg-blue-600 grid mx-8 h-8 place-items-center rounded-xl shadow-md ">
|
||||
<h1 id="my-quizzes" class="flex px-4 font-bold text-white">Eigene Quiz</h1>
|
||||
</div>
|
||||
|
||||
<div class="swiper mySwiper">
|
||||
<div class="swiper-wrapper">
|
||||
{% for quiz in filter_my_quizzes %}
|
||||
<div class="swiper-slide">
|
||||
<div class="relative h-80 bg-white bg-cover text-white w-full max-w-md rounded-lg p-4 shadow-md border-blue-600 border-2 rounded-xl flex flex-col justify-between"
|
||||
{% if quiz.image %} style="background-image: url('http://127.0.0.1:8000/library{{ quiz.image.url }}');" {% endif %}>
|
||||
<div class="absolute inset-0 bg-gray-900/60 bg-opacity-50 rounded-lg"></div>
|
||||
|
||||
<h2 class=" font-bold break-words truncate text-white font-bold text-white drop-shadow-lg custom-outline"> <a href="{% url 'library:detail_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
|
||||
|
||||
<p class="text-white font-bold text-white drop-shadow-lg custom-outline">
|
||||
<span class="break-words line-clamp-2 ">{{ quiz.description }}</span><br>
|
||||
Schwierigkeit: <span class="font-bold">{{ quiz.difficulty }}</span><br>
|
||||
Anzahl der Fragen: <span class="font-bold ">{{ quiz.question.count }}</span><br>
|
||||
Status: <span class="font-bold ">{{ quiz.status }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Buttons bleiben am unteren Rand -->
|
||||
<div class="flex justify-between items-center gap-2 ">
|
||||
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
||||
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
|
||||
<button id="filterButton" class="p-2 hover:bg-gray-100 rounded-lg transition-colors duration-200 flex items-center gap-2 text-gray-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">✏</a>
|
||||
<a href="{% url 'library:delete_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">🗑</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if filter_my_quizzes|length > 1 %}
|
||||
<div class="swiper-button-next"></div>
|
||||
<div class="swiper-button-prev"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Swiper Container für Quiz von anderen Nutzern -->
|
||||
{% if filter_other_quizzes %}
|
||||
<div class="text-center m-8 bg-blue-600 grid mx-8 h-8 place-items-center rounded-xl shadow-md">
|
||||
<h1 id="other-quizzes" class="flex px-4 font-bold text-white">Quiz von anderen Nutzern</h1>
|
||||
</div>
|
||||
|
||||
<div class="swiper mySwiper">
|
||||
<div class="swiper-wrapper">
|
||||
|
||||
|
||||
{% for quiz in filter_other_quizzes %}
|
||||
<div class="swiper-slide">
|
||||
<div class="relative h-80 bg-white bg-cover text-white w-full max-w-md rounded-lg p-4 shadow-md border-blue-600 border-2 rounded-xl flex flex-col justify-between"
|
||||
{% if quiz.image %} style="background-image: url('http://127.0.0.1:8000/library{{ quiz.image.url }}');" {% endif %}>
|
||||
<div class="absolute inset-0 bg-gray-900/60 bg-opacity-50 rounded-lg"></div>
|
||||
|
||||
<h2 class=" font-bold break-words truncate text-white font-bold text-white drop-shadow-lg custom-outline"> <a href="{% url 'library:detail_quiz' quiz.id %}">{{ quiz.name }}</a></h2>
|
||||
|
||||
<p class="text-white font-bold text-white drop-shadow-lg custom-outline">
|
||||
<span class="break-words line-clamp-2 ">{{ quiz.description }}</span><br>
|
||||
Schwierigkeit: <span class="font-bold">{{ quiz.difficulty }}</span><br>
|
||||
Anzahl der Fragen: <span class="font-bold ">{{ quiz.question.count }}</span><br>
|
||||
Erstellt am: <span class="font-bold ">{{ quiz.creation_date }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Buttons bleiben am unteren Rand -->
|
||||
<div class="flex justify-between items-center gap-2 ">
|
||||
<a href="#" class="qp-a-button-small border-2 border-blue-600 text-white font-bold text-white drop-shadow-lg custom-outline">Spiel starten</a>
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="qp-a-button-small border-2 border-blue-600 text-white text-white font-bold text-white drop-shadow-lg custom-outline">✏</a>
|
||||
<div class="flex gap-2">
|
||||
<a href="{% url 'library:overview_quiz' %}?user={{ quiz.user_id }}" class="text-gray-600 text-sm">
|
||||
<button type="submit" class=" text-sm py-1 text-white font-bold text-white drop-shadow-lg custom-outline hover:text-gray-300"> Quiz von {{ quiz.user_id }}</button>
|
||||
<a href="{% url 'library:overview_quiz' %}" class="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Zurücksetzen
|
||||
</a>
|
||||
<a href="{% url 'library:new_quiz' %}" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neues Quiz
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="filterPanel" class="container mx-auto px-4">
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-200">
|
||||
<form action="{% url 'library:overview_quiz' %}" method="get" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<input type="hidden" name="filter" value="1">
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">minimale Anzahl an Fragen</label>
|
||||
<input type="number" name="min_amout_questions" class="border-2 border-gray-300 rounded-lg p-2 w-full" min="1" id="id_min_amout_questions" value="{{ form.min_amout_questions.value|default_if_none:'' }}">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">maximale Anzahl an Fragen</label>
|
||||
<input type="number" name="max_amout_questions" class="border-2 border-gray-300 rounded-lg p-2 w-full" min="1" id="id_max_amout_questions" value="{{ form.max_amout_questions.value|default_if_none:'' }}">
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">veröffentlicht von User</label>
|
||||
<input type="text" name="user" class="border-2 border-gray-300 rounded-lg p-2 w-full" id="id_user" value="{{ form.user.value|default_if_none:'' }}">
|
||||
</div>
|
||||
<div class="md:col-span-3 flex justify-end">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path>
|
||||
</svg>
|
||||
Filtern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 mt-8 mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 id="my-quizzes" class="text-2xl font-bold text-gray-900">Eigene Quiz</h2>
|
||||
<div class="h-0.5 flex-grow bg-gradient-to-r from-blue-600 to-transparent rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 auto-rows-fr">
|
||||
{% for quiz in my_quizzes %}
|
||||
<article class="relative min-h-[20rem] rounded-xl shadow-lg overflow-hidden bg-white flex flex-col">
|
||||
<!-- Image Section (Top) -->
|
||||
<div class="h-48 w-full relative flex-shrink-0">
|
||||
{% if quiz.image %}
|
||||
<div class="relative w-full h-full inset-0 bg-cover bg-center">
|
||||
<!-- Hintergrundbild -->
|
||||
<div class="absolute inset-0 bg-cover bg-center"
|
||||
style="background-image: url({{ quiz.image.image.url }});">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% else %}
|
||||
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600"></div>
|
||||
{% endif %}
|
||||
{% if user.is_authenticated %}
|
||||
{% if quiz.id not in favorite_quiz_ids %}
|
||||
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
|
||||
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-black size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="absolute top-4 right-4 bg-white bg-opacity-60 text-white px-4 py-2 rounded-lg shadow-lg">
|
||||
<a href="{% url 'library:favorite_quiz' quiz.pk %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-yellow-400 size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Content Section (Bottom) -->
|
||||
<div class="flex-grow bg-white p-4 flex flex-col gap-3">
|
||||
<!-- Title -->
|
||||
<h2 class="text-xl font-bold text-gray-900 break-words mb-2">
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}" class="hover:text-blue-600 transition-colors">{{ quiz.name }}</a>
|
||||
</h2>
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-gray-700 break-words mb-3">{{ quiz.description }}</p>
|
||||
|
||||
<!-- Quiz Info Grid -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<!-- Difficulty -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span class="text-gray-800">{{ quiz.difficulty }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Questions Count -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-gray-800">{{ quiz.questions.count }} Fragen</span>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-gray-800">{{ quiz.status }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div class="flex items-center gap-1">
|
||||
{% if quiz.rating_count > 0 %}
|
||||
{% for i in '12345'|make_list %}
|
||||
{% if forloop.counter <= quiz.average_rating|floatformat:0|add:0 %}
|
||||
<span class="text-yellow-500 text-base">★</span>
|
||||
{% else %}
|
||||
<span class="text-gray-300 text-base">★</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="text-gray-600 text-sm">({{ quiz.rating_count }})</span>
|
||||
{% else %}
|
||||
<span class="text-gray-600 text-sm">Noch keine Bewertungen</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authors -->
|
||||
{% if quiz.authors.all %}
|
||||
<div class="flex items-center gap-2 text-sm mt-2">
|
||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span class="text-gray-600">
|
||||
{% for author in quiz.authors.all %}
|
||||
{{ author.username }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-between items-center gap-2 mt-3 border-t pt-3">
|
||||
{% if quiz.questions.count != 0 %}
|
||||
<a href="{% url 'play:create_game' quiz.id %}"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors duration-200 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Spielen
|
||||
</a>
|
||||
{% else %}
|
||||
Fürs Spielen ist min. 1 Frage nötig!
|
||||
{% endif %}
|
||||
<div class="flex gap-1">
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}"
|
||||
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</a>
|
||||
{% if quiz.user_id == request.user %}
|
||||
<a href="{% url 'library:edit_quiz' quiz.id %}"
|
||||
class="p-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'library:delete_quiz' quiz.id %}"
|
||||
class="p-1.5 bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Pagination für eigene Quiz -->
|
||||
{% if my_quizzes.paginator.num_pages > 1 %}
|
||||
<div class="flex justify-center space-x-2 mt-6 mb-8">
|
||||
{% if my_quizzes.has_previous %}
|
||||
<a href="?my_page={{ my_quizzes.previous_page_number }}{% if request.GET.other_page %}&other_page={{ request.GET.other_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="px-3 py-1 text-gray-600">Seite {{ my_quizzes.number }} von {{ my_quizzes.paginator.num_pages }}</span>
|
||||
|
||||
{% if filter_other_quizzes|length > 1 %}
|
||||
<div class="swiper-button-next"></div>
|
||||
<div class="swiper-button-prev"></div>
|
||||
{% if my_quizzes.has_next %}
|
||||
<a href="?my_page={{ my_quizzes.next_page_number }}{% if request.GET.other_page %}&other_page={{ request.GET.other_page }}{% endif %}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
class="px-3 py-1 bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not filter_other_quizzes and not filter_my_quizzes %}
|
||||
<div class="grid text-center font-bold items-center">Es wurde kein Quiz passendes gefunden!</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">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 %}
|
||||
|
||||
|
||||
|
||||
<!-- Swiper.js -->
|
||||
<script src="{% static 'swiper/swiper-bundle.min.js' %}"></script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- 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');
|
||||
|
||||
|
||||
var swiper = new Swiper('.swiper', {
|
||||
slidesPerView: getSlidesPerView(),
|
||||
direction: getDirection(),
|
||||
navigation: {
|
||||
nextEl: '.swiper-button-next',
|
||||
prevEl: '.swiper-button-prev',
|
||||
},
|
||||
on: {
|
||||
resize: function () {
|
||||
swiper.changeDirection(getDirection());
|
||||
swiper.params.slidesPerView = getSlidesPerView();
|
||||
swiper.update();
|
||||
},
|
||||
},
|
||||
// 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}`);
|
||||
});
|
||||
});
|
||||
|
||||
function getDirection() {
|
||||
return window.innerWidth <= 0? 'vertical' : 'horizontal';
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getSlidesPerView() {
|
||||
if (window.innerWidth <= 840) {
|
||||
return 1; // Smartphones
|
||||
} else if (window.innerWidth <= 1024) {
|
||||
return 2; // Tablets
|
||||
} else {
|
||||
return 3; // Desktops
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.custom-outline {
|
||||
-webkit-text-stroke: 0.2px rgb(0, 0, 0); /* Schwarze Umrandung */
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
|
||||
93
django/templates/library/question/question_input.html
Normal file
93
django/templates/library/question/question_input.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% 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 %}
|
||||
@@ -9,9 +9,8 @@
|
||||
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">
|
||||
<form method="post" class="space-y-6" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Question Text -->
|
||||
@@ -24,6 +23,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if image %}
|
||||
<img src="{{ image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
|
||||
<label for="reset_image">
|
||||
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
|
||||
Bild zurücksetzen
|
||||
</label>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label class="font-bold" for="question_image">Bild:</label>
|
||||
<input value="image.url" class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="question_image" id="question_image">
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Antwortmöglichkeiten</label>
|
||||
@@ -92,6 +104,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
|
||||
<select name="time_per_question" id="time_per_question">
|
||||
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
|
||||
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
|
||||
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
|
||||
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
|
||||
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
|
||||
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
|
||||
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</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">
|
||||
<form method="post" class="space-y-6" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Question Text -->
|
||||
@@ -24,6 +24,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if image %}
|
||||
<img src="{{ image.image.url }}" style="max-width: 500px;" alt="Bild der Frage">
|
||||
<label for="reset_image">
|
||||
<input type="checkbox" name="reset_ques_image" id="reset_ques_image">
|
||||
Bild zurücksetzen
|
||||
</label>
|
||||
{% endif %}
|
||||
<div>
|
||||
<label class="font-bold" for="question_image">Bild:</label>
|
||||
<input class="bg-blue-200 p-2 m-2 rounded-lg border-2 border-blue-400" type="file" name="question_image" id="question_image">
|
||||
</div>
|
||||
|
||||
<!-- True/False Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Richtige Antwort</label>
|
||||
@@ -43,6 +55,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<label class="font-bold" for="time_per_question">Zeit pro Frage:</label>
|
||||
<select name="time_per_question" id="time_per_question">
|
||||
<option value="10" {% if standard_time_per_question == "10" or time_per_question == "10" %}selected{% endif %}>10 Sekunden</option>
|
||||
<option value="20" {% if standard_time_per_question == "20" or time_per_question == "20" %}selected{% endif %}>20 Sekunden</option>
|
||||
<option value="30" {% if standard_time_per_question == "30" or time_per_question == "30" %}selected{% endif %}>30 Sekunden</option>
|
||||
<option value="45" {% if standard_time_per_question == "45" or time_per_question == "45" %}selected{% endif %}>45 Sekunden</option>
|
||||
<option value="60" {% if standard_time_per_question == "60" or time_per_question == "60" %}selected{% endif %}>1 Minute</option>
|
||||
<option value="120" {% if standard_time_per_question == "120" or time_per_question == "120" %}selected{% endif %}>2 Minuten</option>
|
||||
<option value="300" {% if standard_time_per_question == "300" or time_per_question == "300" %}selected{% endif %}>5 Minuten</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="{% url 'library:detail_quiz' quiz.id %}"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<nav class="flex justify-between items-center bg-blue-600 h-12 px-4 sm:px-6 lg:px-8 rounded-lg m-2 shadow-md overflow-x-auto whitespace-nowrap">
|
||||
<div class="flex items-center flex-shrink-0">
|
||||
<nav id="main-nav" class="relative bg-blue-600 rounded-lg mt-1 m-2 shadow-md transition-all duration-300">
|
||||
<!-- Desktop Navigation Bar -->
|
||||
<div class="flex justify-between items-center h-12 px-4 sm:px-6 overflow-x-auto whitespace-nowrap">
|
||||
<!-- Logo / Left section -->
|
||||
<div class="flex items-center flex-shrink-0 md:w-32">
|
||||
<h2 class="text-xl font-bold text-white hover:scale-110 transition duration-200">
|
||||
<a href="{% url 'homepage:home' %}">qivip</a>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if show_search %}
|
||||
<form method="get" class="mr-2 ml-2 flex items-center w-full max-w-sm bg-white rounded-full px-4 py-1 shadow-md">
|
||||
<input type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}"
|
||||
class="w-full px-3 py-1 text-black bg-transparent focus:outline-none">
|
||||
<button type="submit" class=" py-1 text-blue-600">
|
||||
🔍
|
||||
<!-- 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 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 class="hidden md:flex"><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
|
||||
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
|
||||
{% else%}
|
||||
<li><a href="{% url 'library:overview_quiz' %}">Bibliothek</a></li>
|
||||
{% endif %}
|
||||
@@ -29,4 +36,158 @@
|
||||
{% 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>
|
||||
|
||||
<!-- Mobile Navigation Menu - Hidden by default - FULLWIDTH DROPDOWN -->
|
||||
<div id="mobile-menu" class="hidden absolute w-full z-50 md:hidden opacity-0 transform -translate-y-2 transition-all duration-300">
|
||||
<div class="bg-blue-500 pt-3 pb-4 rounded-b-lg shadow-inner">
|
||||
<!-- Search in mobile menu -->
|
||||
<form method="get" action="{% url 'library:overview_quiz' %}" class="px-4 mb-4">
|
||||
<div class="flex items-center justify-center bg-white rounded-full px-4 py-2 shadow-md">
|
||||
<input id="quiz-search-mobile" list="quiz-names-mobile" type="text" name="search" placeholder="Suche ..." value="{{ request.GET.search }}"
|
||||
class="w-full text-sm px-2 text-black bg-transparent focus:outline-none">
|
||||
<button class="text-blue-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
89
django/templates/play/game/common_scripts.html
Normal file
89
django/templates/play/game/common_scripts.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% 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>
|
||||
205
django/templates/play/game/finished.html
Normal file
205
django/templates/play/game/finished.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% 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 %}
|
||||
242
django/templates/play/game/question_host.html
Normal file
242
django/templates/play/game/question_host.html
Normal file
@@ -0,0 +1,242 @@
|
||||
{% 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 %}
|
||||
145
django/templates/play/game/question_participant.html
Normal file
145
django/templates/play/game/question_participant.html
Normal file
@@ -0,0 +1,145 @@
|
||||
{% 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 %}
|
||||
@@ -1,5 +1,196 @@
|
||||
<!-->
|
||||
Spieler sehen ihre Punktzahl, Platzierung und Richtig/Falsch
|
||||
{% extends 'base.html' %}
|
||||
|
||||
Host: Scoreboard, Button: 'Weiter'
|
||||
</!-->
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h1 class="text-3xl font-bold mb-6">Ergebnisse</h1>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Aktuelle Frage</h2>
|
||||
<p class="text-lg mb-2">{{ question_data.question }}</p>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{% for option in question_data.options %}
|
||||
<div class="p-4 rounded-lg {% if option.is_correct %}bg-green-100 border-2 border-green-500 {% else %} bg-gray-100 {% endif %}">
|
||||
<p class="text-lg">{{ option.value }}</p>
|
||||
<p class="text-gray-600" id="option-count-{{ forloop.counter0 }}">0 Antworten</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Punktestand</h2>
|
||||
<div id="scoreboard" class="space-y-2">
|
||||
{% for participant in participants %}
|
||||
{% if participant == my_participant or is_host %}
|
||||
<div class="p-4 bg-gray-100 rounded-lg flex justify-between items-center border {% if forloop.counter <= 3 %}border-yellow-500{% else %}border-transparent{% endif %}">
|
||||
<div>
|
||||
<p class="text-lg font-bold">
|
||||
{% if forloop.counter == 1 %}
|
||||
🥇
|
||||
{% elif forloop.counter == 2 %}
|
||||
🥈
|
||||
{% elif forloop.counter == 3 %}
|
||||
🥉
|
||||
{% endif %}
|
||||
{{ participant.display_name }}
|
||||
</p>
|
||||
<p class="text-gray-600">{{ participant.score }} Punkte</p>
|
||||
</div>
|
||||
{% if participant.last_answer_correct %}
|
||||
<span class="text-green-500">✓</span>
|
||||
{% elif participant.last_answer_correct == False %}
|
||||
<span class="text-red-500">✗</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_host %}
|
||||
{% if is_last_question %}
|
||||
<button id="finish-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200">
|
||||
Quiz beenden
|
||||
</button>
|
||||
{% else %}
|
||||
<button id="next-button" class="w-full p-3 rounded-full bg-blue-600 text-white font-bold hover:bg-blue-700 transition-colors duration-200">
|
||||
Nächste Frage
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if is_last_question %}
|
||||
<script>
|
||||
// Hilfsfunktion für Pausen
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const items = document.querySelectorAll('#scoreboard div');
|
||||
const top3 = Array.from(items).slice(0, 3); // Nur die ersten 3 animieren
|
||||
|
||||
// Sofort Startzustände für Top3 setzen, die anderen sofort sichtbar
|
||||
items.forEach((el, index) => {
|
||||
if (index >= 3) {
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'translateY(0)';
|
||||
} else {
|
||||
el.style.opacity = '0';
|
||||
el.style.transform = 'translateY(20px)';
|
||||
}
|
||||
});
|
||||
|
||||
// Warten bevor Animation startet
|
||||
await sleep(1500); // <<< hier die 5 Sekunden Pause
|
||||
|
||||
// Transition-Eigenschaft setzen nach Render-Zyklus
|
||||
requestAnimationFrame(() => {
|
||||
top3.forEach(el => {
|
||||
el.style.transition = 'all 0.8s ease';
|
||||
});
|
||||
|
||||
// Animation für Top 3 starten – von Platz 3 zu 1
|
||||
top3.reverse().forEach((el, index) => {
|
||||
setTimeout(() => {
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'translateY(0)';
|
||||
|
||||
if (index === top3.length - 1) {
|
||||
triggerConfetti();
|
||||
}
|
||||
}, index * 1500); // Zeitversetzt aufdecken
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function triggerConfetti() {
|
||||
const end = Date.now() + 15 * 1000;
|
||||
(function frame() {
|
||||
confetti({ particleCount: 2, angle: 60, spread: 55, origin: { x: 0 } });
|
||||
confetti({ particleCount: 2, angle: 120, spread: 55, origin: { x: 1 } });
|
||||
if (Date.now() < end) requestAnimationFrame(frame);
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% include 'play/game/common_scripts.html' %}
|
||||
<script>
|
||||
const joinCode = '{{ quiz_game.join_code }}';
|
||||
const hostId = '{{ host_id }}';
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const gameSocket = new WebSocket(
|
||||
`${wsScheme}://${window.location.host}/ws/game/${joinCode}/`
|
||||
);
|
||||
|
||||
gameSocket.onmessage = function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'game_state_update' &&
|
||||
data.redirect_url &&
|
||||
(data.action === 'next_question' || data.action === 'finish_game')) {
|
||||
window.location.href = data.redirect_url;
|
||||
} else if (data.type === 'answer_stats') {
|
||||
updateAnswerStats(data.stats);
|
||||
}
|
||||
};
|
||||
|
||||
gameSocket.onclose = function(e) {
|
||||
// Normal closure (code 1000) oder Navigation zu einer anderen Seite (code 1001)
|
||||
if (e.code === 1000 || e.code === 1001) {
|
||||
console.log('Game socket closed normally');
|
||||
} else {
|
||||
console.error('Game socket closed unexpectedly', e.code);
|
||||
}
|
||||
};
|
||||
|
||||
{% if is_host %}
|
||||
// Add click handlers for host buttons
|
||||
{% if is_last_question %}
|
||||
var finishButton = document.getElementById('finish-button');
|
||||
if (finishButton) {
|
||||
finishButton.addEventListener('click', function() {
|
||||
gameSocket.send(JSON.stringify({
|
||||
'type': 'finish_game',
|
||||
'host_id': hostId
|
||||
}));
|
||||
});
|
||||
}
|
||||
{% else %}
|
||||
var nextButton = document.getElementById('next-button');
|
||||
if (nextButton) {
|
||||
nextButton.addEventListener('click', function() {
|
||||
gameSocket.send(JSON.stringify({
|
||||
'type': 'next_question',
|
||||
'host_id': hostId
|
||||
}));
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
function updateAnswerStats(stats) {
|
||||
for (var optionIndex in stats) {
|
||||
if (stats.hasOwnProperty(optionIndex)) {
|
||||
var element = document.getElementById('option-count-' + optionIndex);
|
||||
if (element) {
|
||||
|
||||
element.textContent = stats[optionIndex] + ' Antwort(en)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request answer stats when page loads
|
||||
gameSocket.onopen = function(e) {
|
||||
gameSocket.send(JSON.stringify({
|
||||
'type': 'get_answer_stats'
|
||||
}));
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,53 @@
|
||||
<!-->
|
||||
Spieler: Countdown, automatische Weiterleitung
|
||||
{% extends 'base.html' %}
|
||||
|
||||
(Host: Frage)
|
||||
</!-->
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6 text-center">
|
||||
<h1 class="text-3xl font-bold mb-6">Warte auf andere Spieler...</h1>
|
||||
|
||||
<div class="p-4 bg-blue-50 rounded-lg mb-6 inline-block">
|
||||
<p class="text-blue-600 text-xl mb-2">Die nächste Frage beginnt in</p>
|
||||
<p id="countdown" class="text-6xl font-bold text-blue-700">5</p>
|
||||
</div>
|
||||
|
||||
<div class="animate-pulse">
|
||||
<p class="text-gray-600">Bitte warte, bis alle Spieler bereit sind</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% include 'play/game/common_scripts.html' %}
|
||||
<script>
|
||||
const joinCode = '{{ quiz_game.join_code }}';
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const gameSocket = new WebSocket(
|
||||
wsScheme + '://' + window.location.host + '/ws/game/' + joinCode + '/'
|
||||
);
|
||||
|
||||
let countdown = 5;
|
||||
const countdownElement = document.getElementById('countdown');
|
||||
|
||||
gameSocket.onmessage = function(e) {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'game_state_update' && data.action === 'start_question' && data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
};
|
||||
|
||||
gameSocket.onclose = function(e) {
|
||||
wsUtil.handleClose(e);
|
||||
};
|
||||
|
||||
function updateCountdown() {
|
||||
countdownElement.textContent = countdown;
|
||||
if (countdown > 0) {
|
||||
countdown--;
|
||||
setTimeout(updateCountdown, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
updateCountdown();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,63 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<h1>User for a Game:</h1>
|
||||
<div class="input-group border-blue-600 border-2 rounded-xl shadow-md">
|
||||
<form method="post">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||
<h1 class="text-2xl font-bold mb-6 text-center text-blue-600">Spieler einrichten</h1>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-block bg-blue-100 rounded-lg px-4 py-2">
|
||||
<span class="text-sm text-blue-800">Spiel-Code:</span>
|
||||
<span class="font-mono font-bold text-blue-900">{{ join_code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="join_code" value="{{join_code}}">
|
||||
<input type="text" name="display_name" placeholder="Anzeigename">
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg mb-4">
|
||||
<p class="text-sm text-gray-600 mb-2">Wähle einen Anzeigenamen für das Spiel:</p>
|
||||
<input type="text"
|
||||
name="display_name"
|
||||
required
|
||||
maxlength="20"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Dein Spielername">
|
||||
<p class="text-xs text-gray-500 mt-2">Maximal 20 Zeichen</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center space-x-4">
|
||||
<a href="/"
|
||||
class="px-6 py-2 bg-gray-200 hover:bg-gray-300 rounded-lg transition-colors duration-200">
|
||||
Abbrechen
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors duration-200">
|
||||
Spiel beitreten
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="mt-4 p-4 bg-red-100 border-l-4 border-red-500 text-red-700">
|
||||
<p class="font-medium">Fehler</p>
|
||||
<p class="text-sm">{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: calc(100vh - 4rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid place-items-center min-h-[calc(100vh-5rem)] py-6 m-2">
|
||||
<div class="sm:grid sm:place-items-center sm:min-h-[calc(100vh-5rem)] flex justify-center py-6 m-2">
|
||||
<div class="max-w-md w-full p-8 rounded-2xl border-2 border-blue-400 bg-white">
|
||||
<h1 class="text-2xl text-center font-bold mb-4">Spiel beitreten</h1>
|
||||
<form action="{% url 'play:join_game' %}" method="post" class="flex flex-col gap-4">
|
||||
{% csrf_token %}
|
||||
<div class="flex gap-3 items-center justify-center">
|
||||
<input type="text" name="game_code" placeholder="123456" maxlength="6" class="w-32 p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100 text-center tracking-wider" autofocus>
|
||||
<input type="number" name="game_code" placeholder="123456" maxlength="6" class="w-32 p-3 rounded-full bg-gray-200 focus:scale-105 transition duration-200 font-black focus:outline-none focus:ring-2 focus:ring-blue-400 focus:bg-blue-100 text-center tracking-wider" autofocus>
|
||||
<button type="submit" class="h-12 px-6 rounded-full bg-blue-600 text-white font-black hover:scale-105 transition duration-200 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700 active:scale-95 group" disabled>
|
||||
<span class="hidden sm:block">Beitreten</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 transition-transform group-hover:translate-x-1">
|
||||
@@ -15,6 +15,13 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}
|
||||
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</form>
|
||||
<script>
|
||||
const input = document.querySelector('input[name="game_code"]');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user