commit ce0758fbbb1a36fcc53b5b452c4339e732aeb9c4 Author: mrtoine Date: Fri Sep 12 11:11:44 2025 +0200 First Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a04afce --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv +.env +.DS_Store +venv +staticfiles/**/* +media/**/* +*.sqlite3 +**/__pycache__/* diff --git a/commons/bbcode_parser.py b/commons/bbcode_parser.py new file mode 100644 index 0000000..54e7958 --- /dev/null +++ b/commons/bbcode_parser.py @@ -0,0 +1,82 @@ +import re + +class BBCodeParser: + def __init__(self): + # On défini ici les balises BBcode et leur équivalent en html + self.bbcode_patterns = { + r'\[b\](.*?)\[/b\]': r'\1', + r'\[i\](.*?)\[/i\]': r'\1', + r'\[u\](.*?)\[/u\]': r'\1', + r'\[s\](.*?)\[/s\]': r'\1', + r'\[url=(.*?)\](.*?)\[/url\]': r'\2', + r'\[url\](.*?)\[/url\]': r'\1', + r'\[url=(.*?)(?:\s+class=(.*?))?\](.*?)\[/url\]': lambda m: f'{{m.group(3)}}', + r'\[url\](?:\s+class=(.*?))?\](.*?)\[/url\]': lambda m: f'{{m.group(2)}}', + r'\[img alt=(.*?)\](.*?)\[/img\]': r'\1', + r'\[img\](.*?)\[/img\]': r'Image insérer par un utilisateur', + r'\[list\](.*?)\[/list\]': r'', + r'\[\*\](.*?)': r'
  • \1
  • ', + r'\[t1\](.*?)\[/t1\]': r'\1', + r'\[t2\](.*?)\[/t2\]': r'\1', + r'\[t3\](.*?)\[/t3\]': r'\1', + r'\[citation\](.*?)\[/citation\]': r'
    \1
    ', + r'\[citation=(.*?)\](.*?)\[/citation\]': r'
    \1\2
    ', + r'\[quote\](.*?)\[/quote\]': r'
    \1
    ', + r'\[quote=(.*?)\](.*?)\[/quote\]': r'
    \1\2
    ', + r'\[color=(.*?)\](.*?)\[/color\]': r'\2', + r'\[size=(.*?)\](.*?)\[/size\]': r'\2', + r'\[p](.*?)\[/p\]': r'

    \1

    ', + r'\[center\](.*?)\[/center\]': r'
    \1
    ', + r'\[right\](.*?)\[/right\]': r'
    \1
    ', + r'\[hr\]': r'
    ', + r'\[block\](.*?)\[/block\]': r'
    \1
    ', + r'\[block style=(.*?)\](.*?)\[/block\]': r'
    \2
    ', + r'\[block class=(.*?)\](.*?)\[/block\]': r'
    \2
    ', + r'\[block style=(.*?) class=(.*?)\](.*?)\[/block\]': r'
    \3
    ', + r'\[code\](.*?)\[/code\]': lambda m: self._handle_code(m.group(1)), + r'\[code=(.*?)\](.*?)\[/code\]': lambda m: self._handle_code(m.group(2), m.group(1)), + } + + def _handle_code(self, content, language='none'): + # Convertit les retours à la ligne en \n littéraux + escaped_content = ( + content + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''') + .replace('\r\n', '\n') # Normalise les retours à la ligne Windows + .replace('\r', '\n') # Normalise les retours à la ligne Mac + ) + # Enveloppe dans pre/code sans modifier les \n + return f'''
    {escaped_content}
    ''' + + def parse(self, text): + # Protège temporairement le contenu des balises [code] + code_blocks = [] + def save_code(match): + code_blocks.append(match.group(0)) + return f'@@CODE_BLOCK_{len(code_blocks)-1}@@' + + text = re.sub(r'\[code(?:=.*?)?\].*?\[/code\]', save_code, text, flags=re.DOTALL) + + # Parse les autres BBCodes + for bbcode, html in self.bbcode_patterns.items(): + if callable(html): + text = re.sub(bbcode, html, text, flags=re.DOTALL) + else: + text = re.sub(bbcode, html, text, flags=re.DOTALL) + + # Restaure les blocs de code + for i, code_block in enumerate(code_blocks): + if '=' in code_block: # code avec langage spécifié + lang = re.match(r'\[code=(.*?)\]', code_block).group(1) + content = re.search(r'\[code=.*?\](.*?)\[/code\]', code_block, re.DOTALL).group(1) + else: # code sans langage + lang = 'none' + content = re.search(r'\[code\](.*?)\[/code\]', code_block, re.DOTALL).group(1) + + text = text.replace(f'@@CODE_BLOCK_{i}@@', self._handle_code(content, lang)) + + return text \ No newline at end of file diff --git a/forum/__init__.py b/forum/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/forum/admin.py b/forum/admin.py new file mode 100755 index 0000000..8883f6c --- /dev/null +++ b/forum/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin +from .models import Category, Forum, Topic, Post + +class CategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'description', 'created', 'updated') + list_filter = ('created', 'updated') + search_fields = ('name', 'description') + ordering = ('-created',) + fields = ('name', 'description') + +class ForumAdmin(admin.ModelAdmin): + list_display = ('name', 'author', 'category', 'created', 'updated') + list_filter = ('category', 'author', 'created', 'updated') + search_fields = ('name', 'description') + ordering = ('-created',) + fields = ('category', 'author', 'name', 'description') + +class TopicAdmin(admin.ModelAdmin): + list_display = ('title', 'author', 'created', 'updated') + list_filter = ('forum', 'author', 'created', 'updated', 'state') + search_fields = ('title',) + ordering = ('-created',) + fields = ('forum', 'author', 'title', 'state') + +class PostAdmin(admin.ModelAdmin): + list_display = ('type', 'topic', 'author', 'created', 'updated') + list_filter = ('topic', 'author', 'created', 'updated', 'type') + fields = ('topic', 'author', 'content', 'type') + +admin.site.register(Category, CategoryAdmin) +admin.site.register(Forum, ForumAdmin) +admin.site.register(Topic, TopicAdmin) +admin.site.register(Post, PostAdmin) diff --git a/forum/apps.py b/forum/apps.py new file mode 100755 index 0000000..736b8e9 --- /dev/null +++ b/forum/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ForumConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "forum" diff --git a/forum/forms.py b/forum/forms.py new file mode 100755 index 0000000..256fff5 --- /dev/null +++ b/forum/forms.py @@ -0,0 +1,26 @@ +from django import forms + +class CreateTopic(forms.Form): + TOPIC_TYPE_CHOICES = ( + ('normal', 'Normal'), + ('announce', 'Annonce'), + ) + title = forms.CharField( + max_length=150, + label='', + widget=forms.TextInput(attrs={'placeholder': 'Titre du sujet'}) + ) + content = forms.CharField( + label='', + widget=forms.Textarea(attrs={'placeholder': 'Contenu du sujet'}) + ) + type = forms.ChoiceField(choices=TOPIC_TYPE_CHOICES, label='Type de sujet') + +class CreatePost(forms.Form): + content = forms.CharField( + label='', + widget=forms.Textarea(attrs={'placeholder': 'Contenu du message'}) + ) + +class EditPost(forms.Form): + content = forms.CharField(widget=forms.Textarea, label="Message") \ No newline at end of file diff --git a/forum/middleware.py b/forum/middleware.py new file mode 100755 index 0000000..8aedb8b --- /dev/null +++ b/forum/middleware.py @@ -0,0 +1,47 @@ +from django.utils.deprecation import MiddlewareMixin +from .models import Forum, Topic, Post +from users.models import User +from django.db.models import Count + +class ForumStatsMiddleware(MiddlewareMixin): + # On récupère les statistiques du forum pour les affiché dans le menu + def process_request(self, request): + # Nombre total de forums + total_forums = Forum.objects.count() + + # Nombre total de topics + total_topics = Topic.objects.count() + + # Nombre total de posts + total_posts = Post.objects.count() + + # Utilisateur ayant posté le plus de messages + user_with_most_posts = User.objects.annotate(num_posts=Count('post')).order_by('-num_posts').first() + + # Nombre de messages de l'utilisateur ayant posté le plus de messages + most_posts = user_with_most_posts.num_posts if user_with_most_posts else 0 + + # Utilisateur ayant créé le plus de topics + user_with_most_topics = None + most_topics = 0 + for user in User.objects.all(): + if user.topic_set.count() > most_topics: + user_with_most_topics = user + most_topics = user.topic_set.count() + + # Dernier message posté + last_post = Post.objects.latest('created') if total_posts > 0 else None + + # Derneir topic créé + last_topic = Topic.objects.latest('created') if total_topics > 0 else None + + # Ajouter les variables à l'objet request + request.total_forums = total_forums + request.total_topics = total_topics + request.total_posts = total_posts + request.user_with_most_posts = user_with_most_posts + request.most_posts = most_posts + request.user_with_most_topics = user_with_most_topics + request.most_topics = most_topics + request.last_post = last_post + request.last_topic = last_topic \ No newline at end of file diff --git a/forum/migrations/0001_initial.py b/forum/migrations/0001_initial.py new file mode 100755 index 0000000..08cfdf0 --- /dev/null +++ b/forum/migrations/0001_initial.py @@ -0,0 +1,98 @@ +# Generated by Django 4.2.16 on 2024-10-22 11:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=50)), + ("description", models.CharField(max_length=100)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="Forum", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=50)), + ("description", models.CharField(max_length=100)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="forum.category" + ), + ), + ], + ), + migrations.CreateModel( + name="Topic", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("title", models.CharField(max_length=50)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("state", models.CharField(default="open", max_length=10)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "forum", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="forum.forum" + ), + ), + ], + ), + migrations.CreateModel( + name="Post", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("content", models.CharField(max_length=100)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ("type", models.CharField(default="post", max_length=10)), + ("active", models.BooleanField(default=True)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="forum_author", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "topic", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="forum.topic" + ), + ), + ], + ), + ] diff --git a/forum/migrations/0002_alter_category_options_topicread.py b/forum/migrations/0002_alter_category_options_topicread.py new file mode 100755 index 0000000..173068f --- /dev/null +++ b/forum/migrations/0002_alter_category_options_topicread.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.16 on 2024-10-22 18:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("forum", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="category", + options={"verbose_name": "Catégorie", "verbose_name_plural": "Catégories"}, + ), + migrations.CreateModel( + name="TopicRead", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("last_read", models.DateTimeField(auto_now=True)), + ( + "topic", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="forum.topic" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "topic")}, + }, + ), + ] diff --git a/forum/migrations/0003_alter_post_topic.py b/forum/migrations/0003_alter_post_topic.py new file mode 100755 index 0000000..99c77f2 --- /dev/null +++ b/forum/migrations/0003_alter_post_topic.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-22 18:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("forum", "0002_alter_category_options_topicread"), + ] + + operations = [ + migrations.AlterField( + model_name="post", + name="topic", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="posts", + to="forum.topic", + ), + ), + ] diff --git a/forum/migrations/0004_alter_post_content.py b/forum/migrations/0004_alter_post_content.py new file mode 100644 index 0000000..0dc89ac --- /dev/null +++ b/forum/migrations/0004_alter_post_content.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-23 16:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('forum', '0003_alter_post_topic'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='content', + field=models.TextField(), + ), + ] diff --git a/forum/migrations/__init__.py b/forum/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/forum/models.py b/forum/models.py new file mode 100755 index 0000000..e0fcf49 --- /dev/null +++ b/forum/models.py @@ -0,0 +1,87 @@ +from django.db import models +from users.models import User + +class Category(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=50) + description = models.CharField(max_length=100) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Catégorie' + verbose_name_plural = 'Catégories' + + def __str__(self): + return f'ForumCategory({self.id}, {self.name}, {self.description})' + +class Forum(models.Model): + id = models.AutoField(primary_key=True) + category = models.ForeignKey(Category, on_delete=models.CASCADE) + author = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=50) + description = models.CharField(max_length=100) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'Forum({self.id}, {self.category}, {self.name}, {self.description})' + +class Topic(models.Model): + id = models.AutoField(primary_key=True) + forum = models.ForeignKey(Forum, on_delete=models.CASCADE) + author = models.ForeignKey(User, on_delete=models.CASCADE) + title = models.CharField(max_length=50) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + state = models.CharField(max_length=10, default='open') + + def save(self, *args, **kwargs): + if not self.id: + self.state = 'open' + super(Topic, self).save(*args, **kwargs) + + def __str__(self): + return f'Topic({self.id}, {self.forum}, {self.title})' + + def is_unread(self, user): + if not user.is_authenticated: + return False + + last_read = TopicRead.objects.filter( + user=user, + topic=self + ).first() + + if not last_read: + return True + + last_post = Post.objects.filter(topic=self).order_by('-created').first() + if not last_post: + return False + + return last_post.created > last_read.last_read + +class Post(models.Model): + id = models.AutoField(primary_key=True) + topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='posts') + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='forum_author') + content = models.TextField() + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + type = models.CharField(max_length=10, default='post') + active = models.BooleanField(default=True) + + def __str__(self): + return f'Post({self.id}, {self.topic}, {self.author}, {self.content})' + +class TopicRead(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + topic = models.ForeignKey(Topic, on_delete=models.CASCADE) + last_read = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ['user', 'topic'] + + def __str__(self): + return f'TopicRead({self.id}, {self.topic}, {self.user})' \ No newline at end of file diff --git a/forum/templatetags/__init__.py b/forum/templatetags/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/forum/templatetags/_bbcode_tags.py b/forum/templatetags/_bbcode_tags.py new file mode 100644 index 0000000..e68e84d --- /dev/null +++ b/forum/templatetags/_bbcode_tags.py @@ -0,0 +1,9 @@ +from django import template +from commons.bbcode_parser import BBCodeParser + +register = template.Library() + +@register.filter(name='bbcode') +def bbcode(value): + parser = BBCodeParser() + return parser.parse(value) \ No newline at end of file diff --git a/forum/templatetags/forum_extras.py b/forum/templatetags/forum_extras.py new file mode 100755 index 0000000..420e579 --- /dev/null +++ b/forum/templatetags/forum_extras.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) \ No newline at end of file diff --git a/forum/templatetags/paginator_tag.py b/forum/templatetags/paginator_tag.py new file mode 100755 index 0000000..07281ca --- /dev/null +++ b/forum/templatetags/paginator_tag.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.inclusion_tag('components/paginator.html', takes_context=True) +def paginate(context): + return context \ No newline at end of file diff --git a/forum/tests.py b/forum/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/forum/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/forum/urls.py b/forum/urls.py new file mode 100755 index 0000000..d5fb734 --- /dev/null +++ b/forum/urls.py @@ -0,0 +1,20 @@ +from django.urls import path +from django.conf.urls.static import static + +from passion_retro import settings +from . import views + +urlpatterns = [ + path("", views.forum_home, name="forum_home"), + path("/", views.topic_list, name="topic_list"), + path("/create_topic/", views.create_topic, name="create_topic"), + path("//", views.topic_detail, name="post_list"), + + path("/lock/", views.lock_topic, name="lock_topic"), + path("/unlock/", views.unlock_topic, name="unlock_topic"), + path("//deactivate/", views.deactivate_topic, name="deactivate_topic"), + path("//activate/", views.activate_topic, name="activate_topic"), + path("/deactivate/", views.deactivate_post, name="deactivate_post"), + path("/activate/", views.activate_post, name="activate_post"), + path('post//edit/', views.edit_post, name='forum_edit_post'), +] \ No newline at end of file diff --git a/forum/views.py b/forum/views.py new file mode 100755 index 0000000..d513495 --- /dev/null +++ b/forum/views.py @@ -0,0 +1,303 @@ +from django.core.paginator import Paginator +from django.shortcuts import render, redirect +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseForbidden +from django.utils import timezone +from .models import Category, Forum, Topic, Post, TopicRead +from .forms import CreateTopic, CreatePost, EditPost +from django.db.models import Max, F +from users.decorators import groups_required +from users.models import UserLevel + +def forum_home(request): + categories = Category.objects.all() + forums = Forum.objects.all() + + last_posts = {} + count_topics = {} + unread_topics = {} + + for forum in forums: + # Dernier post du forum avec une seule requête + last_post = Post.objects.select_related( + 'topic', + 'author', + 'topic__forum' + ).filter( + topic__forum=forum + ).order_by('-created').first() + + last_posts[forum.id] = last_post + + # Nombre de topics (pas de changement) + count_topic = Topic.objects.filter(forum=forum).count() + count_topics[forum.id] = count_topic + + # Optimisation de la vérification des topics non lus + if request.user.is_authenticated: + # Récupérer le dernier post de chaque topic du forum en une seule requête + latest_posts_per_topic = Post.objects.filter( + topic__forum=forum + ).values('topic_id').annotate( + last_post_date=Max('created') + ) + + # Récupérer les dernières lectures de l'utilisateur pour tous les topics du forum + topic_reads = TopicRead.objects.filter( + user=request.user, + topic__forum=forum + ).values_list('topic_id', 'last_read') + + # Convertir en dictionnaire pour un accès plus rapide + read_dates = dict(topic_reads) + + # Un forum est non lu si au moins un topic a un post plus récent que la dernière lecture + is_unread = False + for post_info in latest_posts_per_topic: + topic_id = post_info['topic_id'] + last_post_date = post_info['last_post_date'] + + # Si le topic n'a jamais été lu + if topic_id not in read_dates: + is_unread = True + break + + # Si le dernier post est plus récent que la dernière lecture + if last_post_date > read_dates[topic_id]: + is_unread = True + break + + unread_topics[forum.id] = is_unread + else: + unread_topics[forum.id] = False + + print(unread_topics) + + context = { + 'categories': categories, + 'forums': forums, + 'last_posts': last_posts, + 'count_topics': count_topics, + 'unread_topics': unread_topics, + } + + return render(request, "components/forum_home.html", context) + +def topic_list(request, forum_id): + forum = Forum.objects.get(id=forum_id) + topics = Topic.objects.filter(forum=forum).order_by('-created') + paginator = Paginator(topics, 20) + + page_number = request.GET.get('page') + topics = paginator.get_page(page_number) + + count_posts = {} + last_posts = {} + page_numbers = {} + unread_topics = {} + + for topic in topics: + count_post = Post.objects.filter(topic=topic).count() + count_posts[topic.id] = count_post + + last_post = Post.objects.filter(topic=topic).order_by('-created').first() + last_posts[topic.id] = last_post + + if request.user.is_authenticated: + # Récupérer le dernier post du topic + latest_post = Post.objects.filter(topic=topic).aggregate(last_post_date=Max('created'))['last_post_date'] + + # Récupérer la dernière lecture de l'utilisateur pour ce topic + topic_read = TopicRead.objects.filter(user=request.user, topic=topic).first() + + # Un topic est non lu si le dernier post est plus récent que la dernière lecture + if topic_read: + unread_topics[topic.id] = latest_post > topic_read.last_read + else: + unread_topics[topic.id] = True + else: + unread_topics[topic.id] = False + + if last_post: + # Position du dernier message (nombre de messages jusqu'à ce message) + post_position = Post.objects.filter( + topic=topic, + created__lte=last_post.created + ).count() + + # Calcul du numéro de page (20 messages par page comme dans topic_detail) + page_number = (post_position + 19) // 20 # équivalent à ceil(post_position/20) + page_numbers[topic.id] = page_number + + context = { + 'forum': forum, + 'topics': topics, + 'count_posts': count_posts, + 'last_posts': last_posts, + 'page_numbers': page_numbers, + 'unread_topics': unread_topics, + 'is_paginated': topics.has_other_pages(), + 'page_obj': topics, + 'paginator': paginator, + } + + return render(request, "forum/topic_list.html", context) + +@login_required +def create_topic(request, forum_id): + if not request.user.is_active: + return HttpResponseForbidden("Vous n'êtes pas autorisé à créer un sujet.") + + if request.method == 'POST': + topic_form = request.POST + # On créer le topic via le model Topic + topic = Topic( + forum=Forum.objects.get(id=forum_id), + author=request.user, + title=topic_form['title'], + ) + + # Puis le premier post du topic + post = Post( + topic=topic, + author=request.user, + content=topic_form['content'], + ) + + topic.save() + post.save() + messages.success(request, 'Topic créer avec succès.') + UserLevel.objects.update(user=request.user, experience=F('experience') + 15) + # on renvoie l'utilisateur sur la page du topic contenu l'id du forum et du topic créer + return redirect('post_list', forum_id=forum_id, topic_id=topic.id) + + forum = Forum.objects.get(id=forum_id) + topic_form = CreateTopic() + context = { + 'forum': forum, + 'topic_form': topic_form, + } + return render(request, "forum/create_topic.html", context) + +def topic_detail(request, topic_id, forum_id): + if request.method == 'POST': + if not request.user.is_active or not request.user.is_authenticated: + return HttpResponseForbidden("Vous n'êtes pas autorisé à poster un message.") + + post_form = CreatePost(request.POST) + if post_form.is_valid(): + post = Post( + topic=Topic.objects.get(id=topic_id), + author=request.user, + content=post_form.cleaned_data['content'], + ) + post.save() + UserLevel.objects.update(user=request.user, experience=F('experience') + 10) + messages.success(request, 'Message posté avec succès.') + + topic = Topic.objects.get(id=topic_id) + posts = Post.objects.filter(topic=topic, active=True) + paginator = Paginator(posts, 20) + + page_number = request.GET.get('page') + posts = paginator.get_page(page_number) + + # Marquer comme lu si l'utilisateur est connecté + if request.user.is_authenticated: + TopicRead.objects.update_or_create( + user=request.user, + topic=topic, + defaults={'last_read': timezone.now()} + ) + + # On compte le nombre de posts de l'auteur + count_posts = {} + for post in posts: + count_post = Post.objects.filter(author=post.author).count() + count_posts[post.author.id] = count_post + + context = { + 'topic': topic, + 'posts': posts, + 'count_posts': count_posts, + 'post_form': CreatePost(), + 'is_paginated': posts.has_other_pages(), + 'page_obj': posts, + 'paginator': paginator, + } + + return render(request, "forum/topic_detail.html", context) + +@groups_required('Modérateur', 'Admininistrateur', 'Super Admin') +def lock_topic(request, topic_id): + topic = Topic.objects.get(id=topic_id) + topic.state = 'closed' + topic.save() + messages.success(request, 'Sujet verrouillé avec succès.') + return redirect('post_list', forum_id=topic.forum.id, topic_id=topic_id) + +@groups_required('Modérateur', 'Admininistrateur', 'Super Admin') +def unlock_topic(request, topic_id): + topic = Topic.objects.get(id=topic_id) + topic.state = 'open' + topic.save() + messages.success(request, 'Sujet déverrouillé avec succès.') + return redirect('post_list', forum_id=topic.forum.id, topic_id=topic_id) + +@groups_required('Modérateur', 'Admininistrateur', 'Super Admin') +def deactivate_post(request, post_id): + post = Post.objects.get(id=post_id) + post.active = False + post.save() + messages.success(request, 'Message désactivé avec succès.') + return redirect('post_list', forum_id=post.topic.forum.id, topic_id=post.topic.id) + +@groups_required('Modérateur', 'Admininistrateur', 'Super Admin') +def activate_post(request, post_id): + post = Post.objects.get(id=post_id) + post.active = True + post.save() + messages.success(request, 'Message activé avec succès.') + return redirect('post_list', forum_id=post.topic.forum.id, topic_id=post.topic.id) + +@groups_required('Modérateur', 'Admininistrateur', 'Super Admin') +def deactivate_topic(request, topic_id): + topic = Topic.objects.get(id=topic_id) + topic.active = False + topic.save() + messages.success(request, 'Sujet désactivé avec succès.') + return redirect('topic_list', forum_id=topic.forum.id) + +@groups_required('Modérateur', 'Admininistrateur', 'Super Admin') +def activate_topic(request, topic_id): + topic = Topic.objects.get(id=topic_id) + topic.active = True + topic.save() + messages.success(request, 'Sujet activé avec succès.') + return redirect('topic_list', forum_id=topic.forum.id) + +@login_required +def edit_post(request, post_id): + post = Post.objects.get(id=post_id) + + # Vérifier si l'utilisateur a le droit d'éditer + if not (request.user == post.author or request.user.groups.filter(name__in=['Administrateur', 'Super Admin']).exists()): + return HttpResponseForbidden("Vous n'êtes pas autorisé à éditer ce message.") + + if request.method == 'POST': + form = EditPost(request.POST) + if form.is_valid(): + post.content = form.cleaned_data['content'] + post.updated = timezone.now() + post.save() + messages.success(request, 'Message modifié avec succès.') + return redirect('post_list', forum_id=post.topic.forum.id, topic_id=post.topic.id) + else: + form = EditPost(initial={'content': post.content}) + + context = { + 'form': form, + 'post': post + } + return render(request, "forum/edit_post.html", context) \ No newline at end of file diff --git a/gallery/__init__.py b/gallery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/admin.py b/gallery/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/gallery/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/gallery/apps.py b/gallery/apps.py new file mode 100644 index 0000000..97a664a --- /dev/null +++ b/gallery/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GalleryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gallery' diff --git a/gallery/forms.py b/gallery/forms.py new file mode 100644 index 0000000..4c708c0 --- /dev/null +++ b/gallery/forms.py @@ -0,0 +1,17 @@ +from django import forms + +class AddImgGallery(forms.Form): + image = forms.FileField( + label='Image', + required=True, + widget=forms.ClearableFileInput(attrs={'class': 'form-inline'}) + ) + + def clean_img(self): + img = self.cleaned_data.get('image') + if img: + if not img.name.lower().endswith(('.jpg', '.jpeg', '.png')): + raise forms.ValidationError('Seul les fichiers JPG, JPEG, PNG sont autorisés.') + if img.size > 5 * 1024 * 1024: + raise forms.ValidationError('La taille de l\'image ne dois pas dépasser 5 Mo.') + return img \ No newline at end of file diff --git a/gallery/migrations/__init__.py b/gallery/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gallery/models.py b/gallery/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/gallery/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/gallery/tests.py b/gallery/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/gallery/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/gallery/urls.py b/gallery/urls.py new file mode 100755 index 0000000..cbbd78f --- /dev/null +++ b/gallery/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from django.conf.urls.static import static + +from passion_retro import settings +from . import views + +urlpatterns = [ + path("", views.home_gallery, name="home_gallery"), + path("import", views.import_img, name="import"), +] \ No newline at end of file diff --git a/gallery/views.py b/gallery/views.py new file mode 100644 index 0000000..9d5eb76 --- /dev/null +++ b/gallery/views.py @@ -0,0 +1,52 @@ +from django.shortcuts import render +from django.contrib import messages +from .forms import AddImgGallery +from django.contrib.auth.decorators import login_required +import os +from django.conf import settings + +@login_required() +def home_gallery(request): + user_id = request.user.id + user_directory = os.path.join(settings.MEDIA_ROOT, 'galleries', str(user_id)) + img_urls = [] + + if os.path.exists(user_directory): + for filename in os.listdir(user_directory): + if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')): + img_urls.append(f"{settings.MEDIA_URL}galleries/{user_id}/{filename}") + + return render(request, 'gallery/home.html', {'images': img_urls}) + +@login_required() +def import_img(request): + if request.method == "POST": + form = AddImgGallery(request.POST, request.FILES) + if form.is_valid(): + image = form.clean_img() + user_id = request.user.id + saved_image_url = save_image_to_gallery(image, user_id) + + messages.success(request, 'Votre image a bien été ajoutée à la galerie') + form = AddImgGallery() + return render(request, 'gallery/form.html', {'form': form}) + +def save_image_to_gallery(image, user_id): + user_directory = os.path.join(settings.MEDIA_ROOT, 'galleries', str(user_id)) + os.makedirs(user_directory, exist_ok=True) + + # Créer un fichier index.html vide pour sécuriser le répertoire + index_file_path = os.path.join(user_directory, 'index.html') + if not os.path.exists(index_file_path): # Vérifie si le fichier n'existe pas déjà + with open(index_file_path, 'w') as index_file: + index_file.write('') # Écrit un fichier vide + + # Définir le chemin complet du fichier + file_path = os.path.join(user_directory, image.name) + + # Enregistrer le fichier + with open(file_path, 'wb+') as destination: + for chunk in image.chunks(): + destination.write(chunk) + + return f"{settings.MEDIA_URL}galleries/{user_id}/{image.name}" \ No newline at end of file diff --git a/games/__init__.py b/games/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/games/admin.py b/games/admin.py new file mode 100644 index 0000000..0b86d66 --- /dev/null +++ b/games/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import LittleBacCategories + +admin.site.register(LittleBacCategories) \ No newline at end of file diff --git a/games/apps.py b/games/apps.py new file mode 100644 index 0000000..1a3efec --- /dev/null +++ b/games/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GamesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'games' diff --git a/games/migrations/0001_initial.py b/games/migrations/0001_initial.py new file mode 100644 index 0000000..4286e2f --- /dev/null +++ b/games/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.17 on 2024-12-30 09:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='LittleBacCategories', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(default='')), + ], + ), + migrations.CreateModel( + name='LittleBacGames', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=155)), + ('status', models.CharField(choices=[('waiting', 'En attente'), ('in_progress', 'En cours'), ('finished', 'Terminée')], default='waiting', max_length=20)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='LittleBacRounds', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('letter', models.CharField(max_length=1)), + ('round_counter', models.IntegerField(default=1)), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_rounds', to='games.littlebacgames')), + ], + ), + migrations.CreateModel( + name='LittleBacPlayers', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('score', models.IntegerField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_players', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LittleBacAnswers', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('answer', models.CharField(max_length=100)), + ('is_valid', models.BooleanField(default=False)), + ('point', models.IntegerField(default=0)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_answers', to='games.littlebaccategories')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_answers', to='games.littlebacplayers')), + ('round', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_answers', to='games.littlebacrounds')), + ], + ), + ] diff --git a/games/migrations/0002_littlebacplayers_game.py b/games/migrations/0002_littlebacplayers_game.py new file mode 100644 index 0000000..868f40c --- /dev/null +++ b/games/migrations/0002_littlebacplayers_game.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.17 on 2024-12-30 10:51 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacplayers', + name='game', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_players', to='games.littlebacgames'), + preserve_default=False, + ), + ] diff --git a/games/migrations/0003_littlebacgames_author.py b/games/migrations/0003_littlebacgames_author.py new file mode 100644 index 0000000..7a0fb38 --- /dev/null +++ b/games/migrations/0003_littlebacgames_author.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.17 on 2024-12-30 12:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('games', '0002_littlebacplayers_game'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacgames', + name='author', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='little_bac_games', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + ] diff --git a/games/migrations/0004_littlebacplayers_is_ready.py b/games/migrations/0004_littlebacplayers_is_ready.py new file mode 100644 index 0000000..c44ed11 --- /dev/null +++ b/games/migrations/0004_littlebacplayers_is_ready.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-30 12:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0003_littlebacgames_author'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacplayers', + name='is_ready', + field=models.BooleanField(default=False), + ), + ] diff --git a/games/migrations/0005_littlebacgames_players.py b/games/migrations/0005_littlebacgames_players.py new file mode 100644 index 0000000..7873662 --- /dev/null +++ b/games/migrations/0005_littlebacgames_players.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-30 15:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0004_littlebacplayers_is_ready'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacgames', + name='players', + field=models.ManyToManyField(related_name='little_bac_games', to='games.littlebacplayers'), + ), + ] diff --git a/games/migrations/0006_remove_littlebacgames_players.py b/games/migrations/0006_remove_littlebacgames_players.py new file mode 100644 index 0000000..0bf1681 --- /dev/null +++ b/games/migrations/0006_remove_littlebacgames_players.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.17 on 2024-12-30 15:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0005_littlebacgames_players'), + ] + + operations = [ + migrations.RemoveField( + model_name='littlebacgames', + name='players', + ), + ] diff --git a/games/migrations/0007_littlebacgames_countdown_started_and_more.py b/games/migrations/0007_littlebacgames_countdown_started_and_more.py new file mode 100644 index 0000000..2823959 --- /dev/null +++ b/games/migrations/0007_littlebacgames_countdown_started_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 4.2.17 on 2024-12-30 19:24 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('games', '0006_remove_littlebacgames_players'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacgames', + name='countdown_started', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='littlebacgames', + name='countdown_time', + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name='littlebacanswers', + name='category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='games.littlebaccategories'), + ), + migrations.AlterField( + model_name='littlebacanswers', + name='player', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='games.littlebacplayers'), + ), + migrations.AlterField( + model_name='littlebacanswers', + name='round', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='games.littlebacrounds'), + ), + migrations.AlterField( + model_name='littlebacgames', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='games', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='littlebacplayers', + name='game', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to='games.littlebacgames'), + ), + migrations.AlterField( + model_name='littlebacplayers', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='littlebacrounds', + name='game', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rounds', to='games.littlebacgames'), + ), + ] diff --git a/games/migrations/0008_littlebacgames_countdown_start_time.py b/games/migrations/0008_littlebacgames_countdown_start_time.py new file mode 100644 index 0000000..0010e13 --- /dev/null +++ b/games/migrations/0008_littlebacgames_countdown_start_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-30 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0007_littlebacgames_countdown_started_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacgames', + name='countdown_start_time', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/games/migrations/0009_alter_littlebacgames_countdown_start_time.py b/games/migrations/0009_alter_littlebacgames_countdown_start_time.py new file mode 100644 index 0000000..a9bcb36 --- /dev/null +++ b/games/migrations/0009_alter_littlebacgames_countdown_start_time.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-31 08:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0008_littlebacgames_countdown_start_time'), + ] + + operations = [ + migrations.AlterField( + model_name='littlebacgames', + name='countdown_start_time', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + ] diff --git a/games/migrations/0010_littlebacplayers_status.py b/games/migrations/0010_littlebacplayers_status.py new file mode 100644 index 0000000..c4e57e0 --- /dev/null +++ b/games/migrations/0010_littlebacplayers_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-31 12:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0009_alter_littlebacgames_countdown_start_time'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacplayers', + name='status', + field=models.CharField(choices=[('playing', 'Joue'), ('overed', 'A fini')], default='playing', max_length=20), + ), + ] diff --git a/games/migrations/0011_littlebacgames_current_phase.py b/games/migrations/0011_littlebacgames_current_phase.py new file mode 100644 index 0000000..47a8deb --- /dev/null +++ b/games/migrations/0011_littlebacgames_current_phase.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2025-01-02 13:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0010_littlebacplayers_status'), + ] + + operations = [ + migrations.AddField( + model_name='littlebacgames', + name='current_phase', + field=models.CharField(blank=True, default='ready_game', max_length=60, null=True), + ), + ] diff --git a/games/migrations/__init__.py b/games/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/games/models.py b/games/models.py new file mode 100644 index 0000000..9b5d0c4 --- /dev/null +++ b/games/models.py @@ -0,0 +1,49 @@ +from django.db import models +from users.models import User + +class LittleBacGames(models.Model): + id = models.AutoField(primary_key=True) + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='games') + name = models.CharField(max_length=155) + status = models.CharField(max_length=20 ,choices=[ + ('waiting', 'En attente'), + ('in_progress', 'En cours'), + ('finished', 'Terminée') + ], default='waiting') + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + countdown_started = models.BooleanField(default=False) + countdown_time = models.IntegerField(default=0) + countdown_start_time = models.DateTimeField(default=None, null=True, blank=True) + current_phase = models.CharField(max_length=60, default="ready_game", null=True, blank=True) + +class LittleBacPlayers(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='players') + game = models.ForeignKey(LittleBacGames, on_delete=models.CASCADE, related_name='players') + score = models.IntegerField() + is_ready = models.BooleanField(default=False) + status = models.CharField(max_length=20, choices=[ + ('playing', 'Joue'), + ('overed', 'A fini') + ], default="playing") + +class LittleBacCategories(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100) + description = models.TextField(default="") + +class LittleBacRounds(models.Model): + id = models.AutoField(primary_key=True) + game = models.ForeignKey(LittleBacGames, on_delete=models.CASCADE, related_name='rounds') + letter = models.CharField(max_length=1) + round_counter = models.IntegerField(default=1) + +class LittleBacAnswers(models.Model): + id = models.AutoField(primary_key=True) + round = models.ForeignKey(LittleBacRounds, on_delete=models.CASCADE, related_name='answers') + player = models.ForeignKey(LittleBacPlayers, on_delete=models.CASCADE, related_name='answers') + category = models.ForeignKey(LittleBacCategories, on_delete=models.CASCADE, related_name='answers') + answer = models.CharField(max_length=100) + is_valid = models.BooleanField(default=False) + point = models.IntegerField(default=0) \ No newline at end of file diff --git a/games/templatetags/custom_filters.py b/games/templatetags/custom_filters.py new file mode 100644 index 0000000..420e579 --- /dev/null +++ b/games/templatetags/custom_filters.py @@ -0,0 +1,7 @@ +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) \ No newline at end of file diff --git a/games/tests.py b/games/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/games/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/games/urls.py b/games/urls.py new file mode 100644 index 0000000..c96eebe --- /dev/null +++ b/games/urls.py @@ -0,0 +1,25 @@ +from django.urls import path +from django.conf.urls.static import static +from . import views + +urlpatterns = [ + # API REST + path('api//start_countdown', views.game_start_countdown, name='game_start_countdown'), + path('api//countdown_status', views.game_countdown_status, name='game_countdown_status'), + path('api/bac//info', views.game_infos_little_bac, name='game_infos_little_bac'), + path('api/bac//info_party', views.party_infos_little_bac, name='party_infos_little_bac'), + path('api/bac//players', views.game_players_little_bac, name='game_players_little_bac'), + path('api/bac//end_game', views.game_liitle_bac_end_game, name='end_game'), + path('api/bac//player//toggle_ready', views.toggle_ready_status_little_bac, name='toggle_ready_status_little_bac'), + + + path("", views.portal, name="portal_games"), + path("bac", views.little_bac_home, name="bac_games"), + path("bac/party", views.little_bac_start, name="bac_start_games"), + path("bac/party/", views.little_bac_party, name="bac_party_games"), + path("bac/party//join", views.little_bac_party_join, name="bac_party_join_games"), + path("bac/party//play", views.little_bac_party_play, name="bac_party_play_games"), + path("bac/party//results", views.game_little_bac_results, name="bac_party_results_games"), + path('party//new_round/', views.game_little_bac_start_new_round, name='bac_start_new_round'), + +] \ No newline at end of file diff --git a/games/views.py b/games/views.py new file mode 100644 index 0000000..d841267 --- /dev/null +++ b/games/views.py @@ -0,0 +1,433 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required +from .models import * +from users.models import UserLevel +from django.utils.timezone import now +from django.contrib import messages +from django.db.models import Count, Sum, F +from django.db.models.functions import Lower + +def portal(request): + last_party = LittleBacGames.objects.filter().last() + nb_parties = LittleBacGames.objects.filter(status="finished").count() + + if not request.user.is_authenticated: + return render(request, 'games/portal.html', {'last_party': last_party, 'nb_parties': nb_parties}) + games = LittleBacGames.objects.filter(author=request.user, status='waiting') + + return render(request, 'games/portal.html', {'games': games, 'last_party': last_party, 'nb_parties': nb_parties}) + +def little_bac_home(request): + return render(request, 'games/littlebac/home.html') + +@login_required() +def little_bac_start(request): + import random + import string + + game = LittleBacGames.objects.create(name=f"Partie de {request.user.username}", author=request.user) + LittleBacPlayers.objects.create( + user=request.user, + game=game, + score=0 + ) + + # Liste des lettres de l'alphabet + alphabet = string.ascii_uppercase # 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + # Sélection aléatoire + random_letter = random.choice(alphabet) + + LittleBacRounds.objects.create( + game=game, + letter=random_letter, + round_counter=1 + ) + + return redirect('bac_party_games', party_id=game.id) + +@login_required() +def little_bac_party(request, party_id): + game = LittleBacGames.objects.get(id=party_id) + players = LittleBacPlayers.objects.filter(game=game) + rounds = LittleBacRounds.objects.filter(game=game) + + if players.filter(user=request.user).exists(): + current_round = rounds.last() + return render(request, 'games/littlebac/game.html', {'game': game, 'players': players, 'round': current_round}) + else: + return redirect('bac_games') + +@login_required() +def little_bac_party_join(request, party_id): + game = LittleBacGames.objects.get(id=party_id) + players = LittleBacPlayers.objects.filter(game=game) + + if players.filter(user=request.user).exists(): + return redirect('bac_games') + + LittleBacPlayers.objects.create( + user=request.user, + game=game, + score=0 + ) + + return redirect('bac_party_games', party_id=game.id) + +@login_required() +def little_bac_party_play(request, party_id): + game = LittleBacGames.objects.get(id=party_id) + players = LittleBacPlayers.objects.filter(game=game) + + if not players.filter(user=request.user).exists(): + return redirect('bac_games') + + # Vérifie si la partie est en attente et met à jour le statut + if game.status == 'waiting': + game.status = 'in_progress' + game.save() + + # Récupère le round actuel (dernier round) + current_round = LittleBacRounds.objects.filter(game=game).last() + categories = LittleBacCategories.objects.all() + player = players.get(user=request.user) + + if request.method == "POST": + print("POST") + # On vérifie que les réponses commencent par la lettre du round + round_letter = current_round.letter.upper() + all_valid = True + for category in categories: + answer = request.POST.get(f"col-{category.id}", "").strip() + if answer and answer[0].upper() != round_letter: + all_valid = False + break + + if all_valid: + player = LittleBacPlayers.objects.get(game=game, user_id=request.user.id) + player.status = "overed" + player.save() + + # Récupérer les réponses soumises par le joueur + answers = { + f"col-{category.id}": request.POST.get(f"col-{category.id}") + for category in categories + } + + responses = [] + # Enregistre chaque réponse en base de données + for category in categories: + answer = answers.get(f"col-{category.id}", "").strip() # Récupère la réponse ou une chaîne vide + if answer: # Vérifie si une réponse est fournie + response = LittleBacAnswers.objects.create( + round=current_round, + player=players.get(user=request.user), + category=category, + answer=answer, + is_valid=False + ) + responses.append(response) + + return render(request, 'games/littlebac/finish.html', { + 'responses': responses, + 'round': current_round, + 'categories': categories, + 'player': player + }) + else: + messages.error(request, "Les réponses doivent commencer par la lettre du tour.") + return render(request, 'games/littlebac/play.html', { + 'game': game, + 'round': current_round, + 'categories': categories, + 'countdown_remaining': countdown_remaining, + }) + + # Passe les informations du décompte au template + countdown_remaining = max( + 0, game.countdown_time - int((now() - game.countdown_start_time).total_seconds()) + ) if game.countdown_started else None + + return render(request, 'games/littlebac/play.html', { + 'game': game, + 'round': current_round, + 'categories': categories, + 'countdown_remaining': countdown_remaining, + 'player': player + }) + +@login_required() +def game_little_bac_results(request, party_id): + game = get_object_or_404(LittleBacGames, id=party_id) + players = LittleBacPlayers.objects.filter(game=game) + rounds = LittleBacRounds.objects.filter(game=game) + categories = LittleBacCategories.objects.all() + + all_organized_answers = {} + scores_by_round = {round.id: {} for round in rounds} + total_scores = {player.id: 0 for player in players} + + for round in rounds: + answers = LittleBacAnswers.objects.filter(round=round) + + # On détermine qu'un mot est valide s'il est unique pour une catégorie donnée + for category in categories: + valid_answers = answers.filter(category=category).annotate( + lower_answer=Lower('answer') + ).values('lower_answer').annotate( + count=Count('lower_answer') + ).filter(count=1) + for answer in valid_answers: + answers.filter(category=category, answer__iexact=answer['lower_answer']).update(is_valid=True, point=5) + + # Marquer les réponses dupliquées et leur attribuer 1 point + duplicate_answers = answers.filter(category=category).annotate( + lower_answer=Lower('answer') + ).values('lower_answer').annotate( + count=Count('lower_answer') + ).filter(count__gt=1) + for answer in duplicate_answers: + answers.filter(category=category, answer__iexact=answer['lower_answer']).update(is_valid=False, point=1) + + # Calcule des points pour chaque joueur pour ce round + for player in players: + player_score = answers.filter( + player=player + ).aggregate( + total=Sum('point') + )['total'] or 0 + + + scores_by_round[round.id][player.id] = player_score + total_scores[player.id] += player_score + + # On ajoute X experience au joueur (X: score du round) + user_level = UserLevel.objects.get(user=player.user) + last_round_updated = request.session.get(f'last_round_updated_{player.user.id}', 0) + if last_round_updated < round.id: + user_level.experience += player_score + user_level.save() + request.session[f'last_round_updated_{player.user.id}'] = round.id + + # Organiser les réponses par joueur et par catégorie pour chaque round + for round in rounds: + answers = LittleBacAnswers.objects.filter(round=round) + organized_answers = {} + for player in players: + organized_answers[player.id] = {} + for category in categories: + answer = answers.filter(player=player, category=category).first() + if answer: + organized_answers[player.id][category.id] = answer.answer + else: + organized_answers[player.id][category.id] = "" + all_organized_answers[round.id] = organized_answers + + # Mettre à jour les scores totaux des joueurs + for player in players: + player.score = total_scores[player.id] + player.save() + + return render(request, 'games/littlebac/results.html', { + 'game': game, + 'players': players, + 'rounds': rounds, + 'categories': categories, + 'all_organized_answers': all_organized_answers, + 'scores_by_round': scores_by_round, + 'total_scores': total_scores + }) + +login_required() +def game_little_bac_start_new_round(request, game_id): + import random + import string + + game = LittleBacGames.objects.get(id=game_id) + if game.author != request.user: + return redirect('bac_party_games', party_id=game_id) + + players = LittleBacPlayers.objects.filter(game=game) + + game.status = 'waiting' + game.countdown_started = False + game.countdown_start_time = None + game.countdown_time = 0 + game.current_phase = "ready_game" + game.save() + + players.update(is_ready=False, status='playing') + + # Sélectionne une lettre aléatoire pour le nouveau round + alphabet = string.ascii_uppercase + letter = random.choice(alphabet) + + # Détermine le numéro du nouveau round + round_counter = game.rounds.count() + 1 + + # Crée un nouveau round + new_round = LittleBacRounds.objects.create( + game=game, + letter=letter, + round_counter=round_counter + ) + + return redirect('bac_party_games', party_id=game_id) + +# API REST DES JEUX +@login_required() +def game_players_little_bac(request, game_id): + try: + game = LittleBacGames.objects.get(id=game_id) + players = LittleBacPlayers.objects.filter(game=game) + players_list = [{"id": player.id, "username": player.user.username, "score": player.score} for player in players] + return JsonResponse({"game_id": game_id, "players": players_list}) + except LittleBacGames.DoesNotExist: + return JsonResponse({"error": "Game not found"}, status=404) + +@login_required() +def toggle_ready_status_little_bac(request, game_id, player_id): + try: + game = LittleBacGames.objects.get(id=game_id) + player = LittleBacPlayers.objects.get(game=game, user_id=player_id) + + player.is_ready = not player.is_ready + player.save() + + # Vérifie si tous les joueurs de la partie sont prêts + game = player.game + print("Joueurs de la partie",game) + all_ready = LittleBacPlayers.objects.filter(game=game, is_ready=True).count() + print(all_ready) + + return JsonResponse({"is_ready": player.is_ready, "all_ready": all_ready}) + except LittleBacPlayers.DoesNotExist: + return JsonResponse({"error": "Player not found"}, status=404) + +def game_infos_little_bac(request, game_id): + try: + game = LittleBacGames.objects.get(id=game_id) + + return JsonResponse({ + "name": game.name, + "status": game.status, + "created": game.created, + "all_ready": game.players.filter(is_ready=True).count() + }) + except LittleBacGames.DoesNotExist: + return JsonResponse({"error": "Party not found"}, status=404) + +@login_required() +def party_infos_little_bac(request, game_id): + from django.utils.timezone import now + + try: + game = LittleBacGames.objects.get(id=game_id) + players = LittleBacPlayers.objects.filter(game=game) + players_data = [ + {"id": player.id, "username": player.user.username, "status": player.status, "score": player.score} + for player in players + ] + + countdown_remaining = None + if game.countdown_started and game.countdown_start_time: + elapsed_time = (now() - game.countdown_start_time).total_seconds() + countdown_remaining = max(0, game.countdown_time - int(elapsed_time)) + + print(game.status) + + return JsonResponse({ + "game_status": game.status, + "players": players_data, + "countdown_time": countdown_remaining, + "countdown_started": game.countdown_started, + "current_phase": game.current_phase if hasattr(game, 'current_phase') else None + }) + except LittleBacGames.DoesNotExist: + return JsonResponse({"error": "Game not found"}, status=404) + +@login_required() +def game_start_countdown(request, game_id): + from django.utils.timezone import now + + # Récupère le type de décompte (ready_game ou finish_game) + countdown_type = request.GET.get("type", "ready_game") + print(f"Type de décompte reçu: {countdown_type}") # Ajout de cette ligne pour vérifier le type de décompte + + if countdown_type not in ["ready_game", "finish_game"]: + return JsonResponse({"success": False, "error": "Type de décompte invalide."}, status=400) + + try: + game = LittleBacGames.objects.get(id=game_id) + print(f"Statut du jeu: {game.status}") # Ajout de cette ligne pour vérifier le statut du jeu + + # Vérification des conditions pour chaque décompte + if countdown_type == "ready_game" and not game.countdown_started and game.status == "waiting": + game.countdown_started = True + game.countdown_start_time = now() + game.countdown_time = 5 + game.save() + + elif countdown_type == "finish_game" and game.status == "in_progress": + print("Finish game") + # Vérifie si un décompte précédent n'est pas actif + elapsed_time = (now() - game.countdown_start_time).total_seconds() + if not game.countdown_started or elapsed_time >= game.countdown_time: + print("Start countdown") + game.countdown_started = True + game.countdown_start_time = now() + game.countdown_time = 60 + game.current_phase = "finish_game" + game.save() + else: + return JsonResponse({"success": False, "error": "Décompte déjà en cours."}, status=400) + + else: + return JsonResponse({"success": False, "error": "Condition de décompte non remplie."}, status=400) + + return JsonResponse({ + "success": True, + "countdown_started": game.countdown_started, + "countdown_time": game.countdown_time + }) + except LittleBacGames.DoesNotExist: + return JsonResponse({"success": False, "error": "Game not found"}, status=404) + +def game_countdown_status(request, game_id): + from django.utils.timezone import now + + game = LittleBacGames.objects.get(id=game_id) + + if game.countdown_started and game.countdown_start_time: + # Temps écoulé en secondes + elapsed_time = (now() - game.countdown_start_time).total_seconds() + remaining_time = max(0, game.countdown_time - int(elapsed_time)) + print(f"Elapsed time: {elapsed_time}, Remaining time: {remaining_time}") # Vérification + else: + print("Ouuups") + remaining_time = game.countdown_time + + return JsonResponse({ + "countdown_started": game.countdown_started, + "countdown_time": remaining_time + }) + +@login_required() +def game_liitle_bac_end_game(request, game_id): + from django.utils.timezone import now + + try: + game = LittleBacGames.objects.get(id=game_id) + + # Mettre à jour l'état du jeu à "finished" + game.status = 'finished' + game.updated = now() + game.save() + + # Optionnel : Mettez à jour tous les joueurs pour les marquer comme ayant terminé + LittleBacPlayers.objects.filter(game=game).update(status='overed') + + return JsonResponse({"success": True, "message": "Game ended successfully."}) + except LittleBacGames.DoesNotExist: + return JsonResponse({"error": "Game not found"}, status=404) \ No newline at end of file diff --git a/guestbook/__init__.py b/guestbook/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/guestbook/admin.py b/guestbook/admin.py new file mode 100755 index 0000000..8c38f3f --- /dev/null +++ b/guestbook/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/guestbook/apps.py b/guestbook/apps.py new file mode 100755 index 0000000..9586043 --- /dev/null +++ b/guestbook/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GuestbookConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "guestbook" diff --git a/guestbook/forms.py b/guestbook/forms.py new file mode 100755 index 0000000..be1a1fa --- /dev/null +++ b/guestbook/forms.py @@ -0,0 +1,12 @@ +from django import forms + +class CreateGuestbook(forms.Form): + author = forms.CharField( + max_length=150, + label='', + widget=forms.TextInput(attrs={'placeholder': 'Nom'}) + ) + content = forms.CharField( + label='', + widget=forms.Textarea(attrs={'placeholder': 'Message'}) + ) \ No newline at end of file diff --git a/guestbook/middleware.py b/guestbook/middleware.py new file mode 100755 index 0000000..6530d44 --- /dev/null +++ b/guestbook/middleware.py @@ -0,0 +1,14 @@ +from django.utils.deprecation import MiddlewareMixin +from guestbook.models import Guestbook + +class GuestbookMiddleware(MiddlewareMixin): + def process_request(self, request): + # On récupère les messages du livre d'or et les auteurs + guestbook = Guestbook.objects.all().order_by('-created')[:5] + + # On compte le nombre de messages + total_guestbook = Guestbook.objects.count() + + # On ajoute les variables à l'objet request + request.guestbook = guestbook + request.total_guestbook = total_guestbook \ No newline at end of file diff --git a/guestbook/migrations/0001_initial.py b/guestbook/migrations/0001_initial.py new file mode 100755 index 0000000..791bd4f --- /dev/null +++ b/guestbook/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.16 on 2024-10-22 11:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Guestbook", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("author", models.CharField(default="Visiteur", max_length=50)), + ("content", models.CharField(max_length=100)), + ("created", models.DateTimeField(auto_now_add=True)), + ("active", models.BooleanField(default=True)), + ], + ), + ] diff --git a/guestbook/migrations/__init__.py b/guestbook/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/guestbook/models.py b/guestbook/models.py new file mode 100755 index 0000000..24cfed1 --- /dev/null +++ b/guestbook/models.py @@ -0,0 +1,11 @@ +from django.db import models + +class Guestbook(models.Model): + id = models.AutoField(primary_key=True) + author = models.CharField(max_length=50, default='Visiteur') + content = models.CharField(max_length=100) + created = models.DateTimeField(auto_now_add=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'Guestbook({self.id}, {self.author}, {self.content}, {self.created})' \ No newline at end of file diff --git a/guestbook/tests.py b/guestbook/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/guestbook/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/guestbook/urls.py b/guestbook/urls.py new file mode 100755 index 0000000..fa5469c --- /dev/null +++ b/guestbook/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from django.conf.urls.static import static + +from passion_retro import settings +from . import views + +urlpatterns = [ + path("", views.guestbook_home, name="guestbook_home"), +] \ No newline at end of file diff --git a/guestbook/views.py b/guestbook/views.py new file mode 100755 index 0000000..05fa9b2 --- /dev/null +++ b/guestbook/views.py @@ -0,0 +1,29 @@ +from django.core.paginator import Paginator +from django.shortcuts import render +from .models import Guestbook +from .forms import CreateGuestbook + +def guestbook_home(request): + # Si un message est envoyé on le traite + if request.method == "POST": + form = CreateGuestbook(request.POST) + if form.is_valid(): + author = form.cleaned_data['author'] + content = form.cleaned_data['content'] + Guestbook.objects.create(author=author, content=content) + + guestbook = Guestbook.objects.all().order_by('-created') + paginator = Paginator(guestbook, 10) + + page_number = request.GET.get('page') + guestbook = paginator.get_page(page_number) + + context = { + 'guestbook': guestbook, + 'is_paginated': guestbook.has_other_pages(), + 'page_obj': guestbook, + 'paginator': paginator, + 'form': CreateGuestbook(), + } + + return render(request, "components/guestbook_home.html", context) \ No newline at end of file diff --git a/home/__init__.py b/home/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/home/admin.py b/home/admin.py new file mode 100755 index 0000000..8c38f3f --- /dev/null +++ b/home/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/home/apps.py b/home/apps.py new file mode 100755 index 0000000..e7d1c7e --- /dev/null +++ b/home/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HomeConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "home" diff --git a/home/migrations/__init__.py b/home/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/home/models.py b/home/models.py new file mode 100755 index 0000000..71a8362 --- /dev/null +++ b/home/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/home/tests.py b/home/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/home/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/home/urls.py b/home/urls.py new file mode 100755 index 0000000..807615d --- /dev/null +++ b/home/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from django.conf.urls.static import static + +from passion_retro import settings +from . import views + +urlpatterns = [ + path("", views.home, name="home"), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/home/views.py b/home/views.py new file mode 100755 index 0000000..2652b08 --- /dev/null +++ b/home/views.py @@ -0,0 +1,16 @@ +from django.shortcuts import render +from posts.models import Post + +def home(request): + edito = Post.objects.filter(type='edito', active=True).first() + news = Post.objects.filter(type='news', active=True).order_by('-created')[:6] + + context = { + 'edito': edito, + 'news': news, + } + + return render(request, "home.html", context) + +def custom_404(request, exception): + return render(request, "errors/404.html", status=404) \ No newline at end of file diff --git a/maintenance/__init__.py b/maintenance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/maintenance/admin.py b/maintenance/admin.py new file mode 100644 index 0000000..a2cb4db --- /dev/null +++ b/maintenance/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import * + +admin.site.register(Informations) \ No newline at end of file diff --git a/maintenance/apps.py b/maintenance/apps.py new file mode 100644 index 0000000..df4a3eb --- /dev/null +++ b/maintenance/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MaintenanceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'maintenance' diff --git a/maintenance/middleware.py b/maintenance/middleware.py new file mode 100644 index 0000000..936f247 --- /dev/null +++ b/maintenance/middleware.py @@ -0,0 +1,22 @@ +from django.shortcuts import redirect +from django.urls import reverse +from django.contrib.auth import get_user + +from .models import Informations + +class MaintenanceMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not request.user.is_superuser and not request.path.startswith('/admin'): + try: + maintenance_info = Informations.objects.get(pk=1) + except Informations.DoesNotExist: + maintenance_info = None + + if maintenance_info and maintenance_info.is_active == True and not request.path.startswith(reverse('maintenance:info')): + return redirect(reverse('maintenance:info')) + + response = self.get_response(request) + return response \ No newline at end of file diff --git a/maintenance/migrations/0001_initial.py b/maintenance/migrations/0001_initial.py new file mode 100644 index 0000000..4ce52af --- /dev/null +++ b/maintenance/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.17 on 2024-12-26 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Informations', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Maintenance en cours', max_length=255, verbose_name='Titre de la maintenance')), + ('content', models.TextField(verbose_name='Contenu de la maintenance')), + ('is_active', models.BooleanField(default=False)), + ], + ), + ] diff --git a/maintenance/migrations/0002_alter_informations_content_and_more.py b/maintenance/migrations/0002_alter_informations_content_and_more.py new file mode 100644 index 0000000..00116a2 --- /dev/null +++ b/maintenance/migrations/0002_alter_informations_content_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.17 on 2025-01-06 17:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('maintenance', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='informations', + name='content', + field=models.TextField(default='Votre site rétro favoris fait un chek-up ;)'), + ), + migrations.AlterField( + model_name='informations', + name='is_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/maintenance/migrations/__init__.py b/maintenance/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/maintenance/models.py b/maintenance/models.py new file mode 100644 index 0000000..9b5f89f --- /dev/null +++ b/maintenance/models.py @@ -0,0 +1,10 @@ +from django.db import models + +class Informations(models.Model): + name = models.CharField("Titre de la maintenance", max_length=255, default="Maintenance en cours") + content = models.TextField(default="Votre site rétro favoris fait un chek-up ;)") + is_active = models.BooleanField(default=True) + + def __str__(self): + return "Contenu de la maintenance" + \ No newline at end of file diff --git a/maintenance/tests.py b/maintenance/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/maintenance/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/maintenance/urls.py b/maintenance/urls.py new file mode 100644 index 0000000..16e2cf7 --- /dev/null +++ b/maintenance/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +app_name = "maintenance" +urlpatterns = [ + path('', views.info, name="info"), +] \ No newline at end of file diff --git a/maintenance/views.py b/maintenance/views.py new file mode 100644 index 0000000..288a20f --- /dev/null +++ b/maintenance/views.py @@ -0,0 +1,6 @@ +from django.shortcuts import render, get_object_or_404 +from .models import Informations + +def info(request): + message = get_object_or_404(Informations, pk=1) + return render(request, 'maintenance/index.html', {'message': message}) \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..488a84a --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passion_retro.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/messagerie/__init__.py b/messagerie/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/messagerie/admin.py b/messagerie/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/messagerie/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/messagerie/apps.py b/messagerie/apps.py new file mode 100644 index 0000000..bde95f3 --- /dev/null +++ b/messagerie/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MessagerieConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'messagerie' diff --git a/messagerie/context_processors.py b/messagerie/context_processors.py new file mode 100644 index 0000000..cef269a --- /dev/null +++ b/messagerie/context_processors.py @@ -0,0 +1,24 @@ +from .models import PrivateMessageSubject, PrivateMessage +from django.db.models import Q + +def pending_pm_count(request): + if request.user.is_authenticated: + # Filtrer les sujets de messages où l'utilisateur est soit le receiver soit le sender + pm_subjects = PrivateMessageSubject.objects.filter( + Q(receiver=request.user) | Q(sender=request.user) + ) + + # Initialiser le compteur de messages non lus + count = 0 + + # Parcourir chaque sujet de message + for subject in pm_subjects: + # Récupérer le dernier message du sujet + last_message = subject.messages.order_by('-date_sent').first() + + # Vérifier si le dernier message n'est pas de l'utilisateur actuel et si le sujet n'est pas lu + if last_message and last_message.author != request.user and not subject.is_read: + count += 1 + + return {'pending_pm_count': count} + return {'pending_pm_count': 0} \ No newline at end of file diff --git a/messagerie/forms.py b/messagerie/forms.py new file mode 100644 index 0000000..ddaa4bc --- /dev/null +++ b/messagerie/forms.py @@ -0,0 +1,23 @@ +# Forms pour la messagerie +from django import forms +from .models import PrivateMessageSubject, PrivateMessage + +class PrivateMessageSubjectForm(forms.ModelForm): + class Meta: + model = PrivateMessageSubject + fields = ['receiver', 'subject'] + labels = { + 'receiver': 'Destinataire', + 'subject': 'Sujet' + } + +class PrivateMessageForm(forms.ModelForm): + class Meta: + model = PrivateMessage + fields = ['message'] + labels = { + 'message': '' + } + widgets = { + 'message': forms.Textarea(attrs={'placeholder': 'Votre message'}) + } \ No newline at end of file diff --git a/messagerie/migrations/0001_initial.py b/messagerie/migrations/0001_initial.py new file mode 100644 index 0000000..1cc63ab --- /dev/null +++ b/messagerie/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.17 on 2025-01-06 10:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PrivateMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(max_length=100)), + ('message', models.TextField()), + ('date_sent', models.DateTimeField(auto_now_add=True)), + ('is_read', models.BooleanField(default=False)), + ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Message privé', + 'verbose_name_plural': 'Messages privés', + 'ordering': ['-date_sent'], + }, + ), + ] diff --git a/messagerie/migrations/0002_remove_privatemessage_is_read_and_more.py b/messagerie/migrations/0002_remove_privatemessage_is_read_and_more.py new file mode 100644 index 0000000..02d0bf2 --- /dev/null +++ b/messagerie/migrations/0002_remove_privatemessage_is_read_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.17 on 2025-01-06 11:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('messagerie', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='privatemessage', + name='is_read', + ), + migrations.RemoveField( + model_name='privatemessage', + name='receiver', + ), + migrations.RemoveField( + model_name='privatemessage', + name='sender', + ), + migrations.AddField( + model_name='privatemessage', + name='author', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='author_messages', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='privatemessage', + name='id', + field=models.AutoField(primary_key=True, serialize=False), + ), + migrations.CreateModel( + name='PrivateMessageSubject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(max_length=100)), + ('is_read', models.BooleanField(default=False)), + ('receiver', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AlterField( + model_name='privatemessage', + name='subject', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='messagerie.privatemessagesubject'), + ), + ] diff --git a/messagerie/migrations/0003_privatemessagesubject_is_active.py b/messagerie/migrations/0003_privatemessagesubject_is_active.py new file mode 100644 index 0000000..23fed35 --- /dev/null +++ b/messagerie/migrations/0003_privatemessagesubject_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2025-01-06 13:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('messagerie', '0002_remove_privatemessage_is_read_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='privatemessagesubject', + name='is_active', + field=models.BooleanField(default=True), + ), + ] diff --git a/messagerie/migrations/__init__.py b/messagerie/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/messagerie/models.py b/messagerie/models.py new file mode 100644 index 0000000..ff309e8 --- /dev/null +++ b/messagerie/models.py @@ -0,0 +1,28 @@ +from django.db import models +from users.models import User + +# Création d'une messagerie privée pour les membres +class PrivateMessageSubject(models.Model): + receiver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='received_messages') + sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_messages') + subject = models.CharField(max_length=100) + is_read = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + + def __str__(self): + return f'Sujet: {self.subject} (De: {self.sender.username} À: {self.receiver.username})' + +class PrivateMessage(models.Model): + id = models.AutoField(primary_key=True) + subject = models.ForeignKey(PrivateMessageSubject, on_delete=models.CASCADE, related_name='messages') + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='author_messages', default=1) + message = models.TextField() + date_sent = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'{self.author.username} ({self.date_sent})' + + class Meta: + verbose_name = 'Message privé' + verbose_name_plural = 'Messages privés' + ordering = ['-date_sent'] \ No newline at end of file diff --git a/messagerie/tests.py b/messagerie/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/messagerie/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/messagerie/urls.py b/messagerie/urls.py new file mode 100644 index 0000000..d81296b --- /dev/null +++ b/messagerie/urls.py @@ -0,0 +1,13 @@ +# urls messagerie + +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.home, name='pm_home'), + path('view//', views.view_message, name='pm_view'), + path('new/', views.new_message, name='pm_new'), + path('delete//', views.delete_subject, name='pm_delete'), + + path('send_all_users/', views.send_all_users, name='pm_send_all_users'), +] diff --git a/messagerie/views.py b/messagerie/views.py new file mode 100644 index 0000000..37b54da --- /dev/null +++ b/messagerie/views.py @@ -0,0 +1,122 @@ +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +from .models import PrivateMessage, PrivateMessageSubject +from django.db import models +from django.db.models import Max +from .forms import PrivateMessageForm, PrivateMessageSubjectForm +from django.contrib import messages + +@login_required +def home(request): + private_messages = PrivateMessageSubject.objects.filter( + (models.Q(receiver=request.user) | models.Q(sender=request.user)) & models.Q(is_active=True) + ).annotate(last_message_date=Max('messages__date_sent')).order_by('-last_message_date') + + return render(request, 'messagerie/home.html', {'private_messages': private_messages}) + +@login_required +def view_message(request, message_id): + pm_message = PrivateMessageSubject.objects.get(pk=message_id) + if (pm_message.receiver != request.user and pm_message.sender != request.user) or not pm_message.is_active: + return redirect('pm_home') + + private_messages = PrivateMessage.objects.filter(subject=pm_message).order_by('date_sent') + + form = PrivateMessageForm(request.POST or None) + + if not pm_message.is_read and pm_message.messages.first().author != request.user: + pm_message.is_read = True + pm_message.save() + + if form.is_valid(): + message = form.save(commit=False) + message.author = request.user + message.subject = pm_message + message.save() + + pm_message.is_read = False + pm_message.save() + + form = PrivateMessageForm() + + return render(request, 'messagerie/view_message.html', {'pm_message': pm_message, 'private_messages': private_messages, 'form': form}) + + return render(request, 'messagerie/view_message.html', {'pm_message': pm_message, 'private_messages': private_messages, 'form': form}) + +@login_required +def new_message(request): + form_subject = PrivateMessageSubjectForm(request.POST or None) + form_message = PrivateMessageForm(request.POST or None) + + if form_subject.is_valid() and form_message.is_valid(): + if form_subject.cleaned_data['receiver'] == request.user: + messages.error(request, 'Vous ne pouvez pas vous envoyer un message à vous-même') + return redirect('pm_home') + subject = form_subject.save(commit=False) + subject.sender = request.user + subject.is_read = False + subject.save() + + message = form_message.save(commit=False) + message.author = request.user + message.subject = subject + message.save() + + return redirect('pm_view', message_id=message.id) + + return render(request, 'messagerie/new_message.html', {'form_subject': form_subject, 'form_message': form_message}) + +def delete_subject(request, message_id): + pm_message = PrivateMessageSubject.objects.get(pk=message_id) + if pm_message.receiver != request.user and pm_message.sender != request.user: + return redirect('pm_home') + + pm_message.is_active = False + pm_message.is_read = True + pm_message.save() + + messages.success(request, 'Le sujet a bien été supprimé') + + return redirect('pm_home') + +@login_required +def send_all_users(request): + from users.models import User + all_users = User.objects.all() + sender = User.objects.get(username='RetroBot') + for user in all_users: + subject = PrivateMessageSubject.objects.create( + receiver=user, + sender=User.objects.get(username='RetroBot'), + subject="Bienvenue sur PassionRetro !" + ) + + PrivateMessage.objects.create( + subject=subject, + author=User.objects.get(username='RetroBot'), + message=f"""[b]Bienvenue sur Passion Retro ![/b] + +Salut [b]{user.username}[/b] !, + +Merci de nous avoir rejoints dans cette aventure dédiée aux passionnés de rétro ! Que tu sois fan de consoles vintage, collectionneur d'objets d'époque ou simple curieux, tu es ici chez toi. + +✨ [b]Découvre tout ce que Passion Retro a à offrir :[/b] +Plonge dans nos articles pour en apprendre plus sur les trésors du passé, participe aux discussions sur le forum et teste tes connaissances avec nos jeux rétro. Chaque section est là pour te permettre de partager ta passion et d'en apprendre davantage. + +🚀 [b]Rejoins la communauté :[/b] +Ton avis et tes contributions sont précieux ! N’hésite pas à lancer une discussion sur le forum, à réagir aux articles ou à défier les autres membres sur nos jeux. Plus nous sommes actifs, plus l’expérience sera enrichissante pour tous. + +Si tu as des questions ou des suggestions pour améliorer le site, contacte-nous. Nous sommes là pour t'accompagner ! + +Encore une fois, bienvenue parmi nous et prépare-toi à replonger dans l’univers du rétro ! + +À bientôt, +[b]L'équipe Passion Retro[/b]""") + + subject = PrivateMessageSubject.objects.create(receiver=user, sender=sender, subject='Bienvenue sur la messagerie privée') + PrivateMessage.objects.create( + subject=subject, + author=sender, + message=f"Cher [b]{user.username}[/b],\n\nBienvenue sur la messagerie privée de passion-retro. Tu peux désormais échanger des messages privés avec les autres membres du site.\n\nQuant a moi, je suis un nouvel utilisateur du site venu de la planète [b]Retronia[/b] ! J'ai été investis de la mission la plus importante de ma longue vie : [u]être le meilleur assistant de passion-retro[/u] ! J'espère sincèrement y parvenir !\n\nJe suis encore en orbite actuellement, mais guette les infos car je serait bientôt présent !\n\nCordialement,\n{sender.username}" + ) + return redirect('pm_home') \ No newline at end of file diff --git a/passion_retro/__init__.py b/passion_retro/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/passion_retro/asgi.py b/passion_retro/asgi.py new file mode 100755 index 0000000..94f1341 --- /dev/null +++ b/passion_retro/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for passion_retro project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passion_retro.settings") + +application = get_asgi_application() diff --git a/passion_retro/settings.py b/passion_retro/settings.py new file mode 100755 index 0000000..b37dc0b --- /dev/null +++ b/passion_retro/settings.py @@ -0,0 +1,199 @@ +""" +Django settings for passion_retro project. + +Generated by 'django-admin startproject' using Django 4.2.16. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path +import os +from dotenv import load_dotenv +import json + +load_dotenv() + +if os.getenv('DATABASE_ENGINE') == "django.db.backends.mysql": + print("c'est mariaDB") + import pymysql + pymysql.install_as_MySQLdb() + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('SECRET_KEY') +GOOGLE_PUBLIC_KEY = os.getenv('GOOGLE_PUBLIC_KEY') +GOOGLE_PRIVATE_KEY = os.getenv('GOOGLE_PRIVATE_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv('DEBUG') == 'True' + +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(',') +CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS').split(',') +USE_X_FORWARDED_HOST = os.getenv('USE_X_FORWARDED_HOST') == 'True' +SECURE_PROXY_SSL_HEADER = tuple(os.getenv('SECURE_PROXY_SSL_HEADER').split(',')) + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # "django.contrib.sites", + + "maintenance", + "home", + "posts", + "users", + "gallery", + "forum", + "tchat", + "guestbook", + "games", + "quiz", + "messagerie", + "shop", +] + +SITE_ID = 1 + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "users.middleware.UserStatsMiddleware", + "forum.middleware.ForumStatsMiddleware", + "guestbook.middleware.GuestbookMiddleware", + "posts.middleware.PostsMiddleware", + 'maintenance.middleware.MaintenanceMiddleware', + "users.middleware.UserLevelMiddleware", + "users.middleware.UserLevelUpMiddleware", +] + +ROOT_URLCONF = "passion_retro.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": (BASE_DIR, 'templates'), + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "posts.context_processors.pending_posts_count", + "quiz.context_processors.pending_quizes_count", + "messagerie.context_processors.pending_pm_count", + ], + }, + }, +] + +WSGI_APPLICATION = "passion_retro.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE'), # Utilise MySQL/MariaDB + 'NAME': os.getenv('DATABASE_NAME') if os.getenv('DATABASE_ENGINE') == "django.db.backends.mysql" else BASE_DIR / os.getenv('DATABASE_NAME'), + 'USER': os.getenv('DATABASE_USER'), + 'PASSWORD': os.getenv('DATABASE_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT', '3306'), # Port par défaut de MySQL/MariaDB + 'OPTIONS': { + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + 'charset': 'utf8mb4', + } + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "fr-FR" + +TIME_ZONE = "Europe/Paris" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +# URL pour accéder aux fichiers statiques +STATIC_URL = '/static/' + +# Chemin absolu vers le répertoire où collecter les fichiers statiques +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') + +# Répertoires supplémentaires pour rechercher les fichiers statiques +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'static'), +] + +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +MEDIA_URL = '/media/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGOUT_REDIRECT_URL = os.getenv('LOGOUT_REDIRECT_URL', default='/') +LOGIN_URL = os.getenv('LOGIN_URL', default='/login/') + +AUTH_USER_MODEL = os.getenv('AUTH_USER_MODEL', default='users.User') + +EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend') +EMAIL_HOST = os.getenv('EMAIL_HOST', default='smtp.gmail.com') +EMAIL_PORT = os.getenv('EMAIL_PORT', default=587) +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', default=True) +EMAIL_USE_SSL = os.getenv('EMAIL_USE_TLS', default=False) +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', default='webmaster@localhost') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', default='password') +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', default='webmaster@localhost') \ No newline at end of file diff --git a/passion_retro/urls.py b/passion_retro/urls.py new file mode 100755 index 0000000..d14f136 --- /dev/null +++ b/passion_retro/urls.py @@ -0,0 +1,40 @@ +""" +URL configuration for passion_retro project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +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.contrib import admin +from django.contrib.auth import views as auth_views +from django.urls import include, path +from . import views + +urlpatterns = [ + path("admin/", admin.site.urls), + path("maintenance/", include("maintenance.urls")), + path("", include("home.urls")), + path("posts/", include("posts.urls")), + path("forum/", include("forum.urls")), + path("users/", include("users.urls")), + path("guestbook/", include("guestbook.urls")), + path("gallery/", include("gallery.urls")), + path("games/", include("games.urls")), + path("quiz/", include("quiz.urls")), + path("pm/", include("messagerie.urls")), + path("shop/", include("shop.urls")), + + path('logout/', auth_views.LogoutView.as_view(), name='logout'), +] + +handler404 = 'home.views.custom_404' \ No newline at end of file diff --git a/passion_retro/views.py b/passion_retro/views.py new file mode 100644 index 0000000..00326b4 --- /dev/null +++ b/passion_retro/views.py @@ -0,0 +1,4 @@ +from django.shortcuts import render + +def custom_404(request, exception): + return render(request, "errors/404.html", status=404) \ No newline at end of file diff --git a/passion_retro/wsgi.py b/passion_retro/wsgi.py new file mode 100755 index 0000000..cde1897 --- /dev/null +++ b/passion_retro/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for passion_retro project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "passion_retro.settings") + +application = get_wsgi_application() diff --git a/posts/__init__.py b/posts/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/posts/admin.py b/posts/admin.py new file mode 100755 index 0000000..ce1fb64 --- /dev/null +++ b/posts/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from django.db.models import QuerySet +from .models import Post, Category + +@admin.action(description="Activer les posts séléctionnés") +def activate_posts(modelAdmin, request, querySet: QuerySet): + updated = querySet.update(active=True) + modelAdmin.message_user(request, f"{updated} post(s) ont été activé(s).") + +@admin.action(description="Désactiver les posts séléctionnés") +def deactivate_posts(modelAdmin, request, querySet: QuerySet): + updated = querySet.update(active=False) + modelAdmin.message_user(request, f"{updated} post(s) ont été désactivé(s).") + +class PostAdmin(admin.ModelAdmin): + list_display = ('title', 'category', 'author', 'type', 'active', 'created', 'updated') + list_filter = ('category', 'type', 'active', 'created', 'updated') + search_fields = ('title', 'content', 'author__username') + ordering = ('-created',) + fields = ('parent', 'post_parent', 'title', 'slug', 'category', 'content', 'type', 'image', 'author', 'active') + prepopulated_fields = {'slug': ('title',)} + actions = [activate_posts, deactivate_posts] + +admin.site.register(Post, PostAdmin) +admin.site.register(Category) \ No newline at end of file diff --git a/posts/apps.py b/posts/apps.py new file mode 100755 index 0000000..81782a2 --- /dev/null +++ b/posts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PostsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "posts" diff --git a/posts/context_processors.py b/posts/context_processors.py new file mode 100644 index 0000000..12a65cc --- /dev/null +++ b/posts/context_processors.py @@ -0,0 +1,7 @@ +from .models import Post +from users.decorators import groups_required + +groups_required('Administrateur', 'SuperAdmin') +def pending_posts_count(request): + count = Post.objects.filter(active=False).count() + return {'pending_posts_count': count} \ No newline at end of file diff --git a/posts/forms.py b/posts/forms.py new file mode 100755 index 0000000..43213ea --- /dev/null +++ b/posts/forms.py @@ -0,0 +1,31 @@ +from django import forms +from .models import Post + +class CreatePost(forms.Form): + title = forms.CharField( + max_length=150, + label='', + required=True, + widget=forms.TextInput(attrs={'placeholder': 'Titre du post'}) + ) + + content = forms.CharField( + label='', + required=True, + widget=forms.Textarea(attrs={'placeholder': 'Contenu du post'}) + ) + + active = forms.BooleanField( + required=True, + label='Actif', + initial=True + ) + +class EditPost(forms.ModelForm): + class Meta: + model = Post + fields = ['title', 'content'] + widgets = { + 'title': forms.TextInput(attrs={'placeholder': 'Titre du post'}), + 'content': forms.Textarea(attrs={'placeholder': 'Contenu du post'}), + } \ No newline at end of file diff --git a/posts/middleware.py b/posts/middleware.py new file mode 100755 index 0000000..ad56813 --- /dev/null +++ b/posts/middleware.py @@ -0,0 +1,23 @@ +from django.utils.deprecation import MiddlewareMixin +from .models import Post + +class PostsMiddleware(MiddlewareMixin): + def process_request(self, request): + + # On récupère tous les posts de type 'games' + posts_games = Post.objects.filter(type='games', active=True, parent=True) + + # On récupère tous les posts de type 'movies' + posts_movies = Post.objects.filter(type='movies', active=True, parent=True) + + # On récupère tous les posts de type 'music' + posts_music = Post.objects.filter(type='music', active=True, parent=True) + + # On récupère tous les posts de type 'tech' + posts_tech = Post.objects.filter(type='tech', active=True, parent=True) + + # on met tout ça dans l'objet request + request.posts_games = posts_games + request.posts_movies = posts_movies + request.posts_music = posts_music + request.posts_tech = posts_tech \ No newline at end of file diff --git a/posts/migrations/0001_initial.py b/posts/migrations/0001_initial.py new file mode 100755 index 0000000..f7d20d9 --- /dev/null +++ b/posts/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.16 on 2024-10-21 18:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Category", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=200)), + ("created", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="Post", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("title", models.CharField(max_length=200)), + ("content", models.TextField()), + ("date", models.DateTimeField(auto_now_add=True)), + ("type", models.CharField(default="news", max_length=200)), + ( + "image", + models.ImageField(blank=True, null=True, upload_to="images/"), + ), + ("active", models.BooleanField(default=False)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/posts/migrations/0002_initial.py b/posts/migrations/0002_initial.py new file mode 100755 index 0000000..aca4274 --- /dev/null +++ b/posts/migrations/0002_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.16 on 2024-10-21 18:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("users", "0001_initial"), + ("posts", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="author", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="users.user" + ), + ), + migrations.AddField( + model_name="post", + name="category", + field=models.ForeignKey( + default=1, + on_delete=django.db.models.deletion.CASCADE, + to="posts.category", + ), + ), + ] diff --git a/posts/migrations/0003_alter_category_options_alter_post_options_post_slug.py b/posts/migrations/0003_alter_category_options_alter_post_options_post_slug.py new file mode 100755 index 0000000..aa78384 --- /dev/null +++ b/posts/migrations/0003_alter_category_options_alter_post_options_post_slug.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2024-10-21 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("posts", "0002_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="category", + options={"verbose_name": "Categorie", "verbose_name_plural": "Categories"}, + ), + migrations.AlterModelOptions( + name="post", + options={"verbose_name": "Post", "verbose_name_plural": "Posts"}, + ), + migrations.AddField( + model_name="post", + name="slug", + field=models.SlugField(default="default-slug", max_length=200, unique=True), + ), + ] diff --git a/posts/migrations/0004_post_forum_link.py b/posts/migrations/0004_post_forum_link.py new file mode 100755 index 0000000..bb267f1 --- /dev/null +++ b/posts/migrations/0004_post_forum_link.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-23 11:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("posts", "0003_alter_category_options_alter_post_options_post_slug"), + ] + + operations = [ + migrations.AddField( + model_name="post", + name="forum_link", + field=models.CharField(blank=True, max_length=200, null=True), + ), + ] diff --git a/posts/migrations/0005_remove_post_date.py b/posts/migrations/0005_remove_post_date.py new file mode 100755 index 0000000..43e1939 --- /dev/null +++ b/posts/migrations/0005_remove_post_date.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-10-24 21:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("posts", "0004_post_forum_link"), + ] + + operations = [ + migrations.RemoveField( + model_name="post", + name="date", + ), + ] diff --git a/posts/migrations/0006_post_contribution.py b/posts/migrations/0006_post_contribution.py new file mode 100644 index 0000000..48b8b1c --- /dev/null +++ b/posts/migrations/0006_post_contribution.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-16 11:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0005_remove_post_date'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='contribution', + field=models.BooleanField(default=False), + ), + ] diff --git a/posts/migrations/0007_post_post_parent.py b/posts/migrations/0007_post_post_parent.py new file mode 100644 index 0000000..78f1ed9 --- /dev/null +++ b/posts/migrations/0007_post_post_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.17 on 2024-12-23 16:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0006_post_contribution'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='post_parent', + field=models.IntegerField(default=0), + preserve_default=False, + ), + ] diff --git a/posts/migrations/0008_alter_post_post_parent.py b/posts/migrations/0008_alter_post_post_parent.py new file mode 100644 index 0000000..65cf5c4 --- /dev/null +++ b/posts/migrations/0008_alter_post_post_parent.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-23 16:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0007_post_post_parent'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='post_parent', + field=models.IntegerField(default=0), + ), + ] diff --git a/posts/migrations/0009_alter_post_post_parent.py b/posts/migrations/0009_alter_post_post_parent.py new file mode 100644 index 0000000..ac9554e --- /dev/null +++ b/posts/migrations/0009_alter_post_post_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.17 on 2024-12-23 16:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0008_alter_post_post_parent'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='post_parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='posts.post'), + ), + ] diff --git a/posts/migrations/0010_post_parent.py b/posts/migrations/0010_post_parent.py new file mode 100644 index 0000000..ab1e67a --- /dev/null +++ b/posts/migrations/0010_post_parent.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2024-12-23 17:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0009_alter_post_post_parent'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='parent', + field=models.BooleanField(default=True), + ), + ] diff --git a/posts/migrations/0011_alter_post_post_parent.py b/posts/migrations/0011_alter_post_post_parent.py new file mode 100644 index 0000000..ddd5bae --- /dev/null +++ b/posts/migrations/0011_alter_post_post_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.17 on 2024-12-26 19:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('posts', '0010_post_parent'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='post_parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_posts', to='posts.post'), + ), + ] diff --git a/posts/migrations/__init__.py b/posts/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/posts/models.py b/posts/models.py new file mode 100755 index 0000000..c624f83 --- /dev/null +++ b/posts/models.py @@ -0,0 +1,59 @@ +from django.db import models +from users.models import User +from commons.bbcode_parser import BBCodeParser +from django.db.models.signals import pre_delete +from django.dispatch import receiver + +class Category(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=200) + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = 'Categorie' + verbose_name_plural = 'Categories' + +class Post(models.Model): + id = models.AutoField(primary_key=True) + slug = models.SlugField(max_length=200, unique=True, default='default-slug') + category = models.ForeignKey(Category, on_delete=models.CASCADE, default=1) + title = models.CharField(max_length=200) + content = models.TextField() + type = models.CharField(max_length=200, default='news') + image = models.ImageField(upload_to='images/', null=True, blank=True) + author = models.ForeignKey(User, on_delete=models.CASCADE) + active = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + contribution = models.BooleanField(default=False) + forum_link = models.CharField(max_length=200, null=True, blank=True) + post_parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='child_posts') + parent = models.BooleanField(default=True) + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + # Si le post a un parent, il ne doit pas être considéré comme parent + if self.post_parent: + self.parent = False + else: + self.parent = True + super().save(*args, **kwargs) + + + class Meta: + verbose_name = 'Post' + verbose_name_plural = 'Posts' + +@receiver(pre_delete, sender=Post) +def handle_parent_deletion(sender, instance, **kwargs): + # Si le post supprimé a des enfants + for child in instance.child_posts.all(): + # Rendre l'enfant un parent + child.post_parent = None + child.parent = True + child.save() \ No newline at end of file diff --git a/posts/templatetags/__init__.py b/posts/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/posts/templatetags/bbcode_tags.py b/posts/templatetags/bbcode_tags.py new file mode 100644 index 0000000..e68e84d --- /dev/null +++ b/posts/templatetags/bbcode_tags.py @@ -0,0 +1,9 @@ +from django import template +from commons.bbcode_parser import BBCodeParser + +register = template.Library() + +@register.filter(name='bbcode') +def bbcode(value): + parser = BBCodeParser() + return parser.parse(value) \ No newline at end of file diff --git a/posts/tests.py b/posts/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/posts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/posts/urls.py b/posts/urls.py new file mode 100755 index 0000000..e9e506a --- /dev/null +++ b/posts/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from django.conf.urls.static import static + +from passion_retro import settings +from . import views + +urlpatterns = [ + path("/", views.view_post, name="view_post"), + + path("create//", views.create_post, name="create_news"), + path("/edit", views.edit_post, name="edit_post"), +] \ No newline at end of file diff --git a/posts/views.py b/posts/views.py new file mode 100755 index 0000000..085d2dc --- /dev/null +++ b/posts/views.py @@ -0,0 +1,108 @@ +from django.shortcuts import render, get_object_or_404, redirect +from posts.models import Post +from forum.models import Topic, Forum, Post as ForumPost +from users.models import UserLevel +from django.contrib import messages +from users.decorators import groups_required +from posts.forms import CreatePost, EditPost +from django.utils.text import slugify +from django.contrib.auth.decorators import login_required +from django.db.models import F + +def view_post(request, slug): + post = Post.objects.filter(slug=slug, active=True).first() + subposts = None + + if post.parent: + subposts = Post.objects.filter(post_parent=post.id) + + context = { + 'post': post, + 'subposts': subposts + } + + return render(request, "posts/post.html", context) + +@groups_required('Rédacteur', 'Admininistrateur', 'Super Admin') +def create_post(request, type): + # Create a new post + if request.method == 'POST': + title = request.POST.get('title') + content = request.POST.get('content') + active = request.POST.get('active') == 'on' + image=request.FILES.get('image') if type == 'news' else None + + # Si c'est une news, on créer aussi un topic sur le forum news + if type == 'news': + topic = Topic.objects.create( + title=title, + forum=Forum.objects.get(name='Actus du site'), + author=request.user + ) + + content_forum = f'

    {content}

    ' + + forum_post = ForumPost.objects.create( + topic=topic, + content=content_forum, + author=request.user + ) + + UserLevel.objects.update(user=request.user, experience=F('experience') + 20) + + topic.save() + forum_post.save() + + forum_link = f'forum/{topic.forum.id}/{topic.id}/' + + post = Post.objects.create( + slug=slugify(title), + title=title, + content=content, + type=type, + image=image, + author=request.user, + active=active, + forum_link=forum_link + ) + + post.save() + + + + messages.success(request, 'Post créer avec succès !') + + return redirect('home') + + context = { + 'type': type, + 'post_form': CreatePost(), + } + + return render(request, "posts/create_post.html", context) + +@login_required() +def edit_post(request, post_id): + post = get_object_or_404(Post, id=post_id, author=request.user) + + if request.method == 'POST': + form = EditPost(request.POST, instance=post) + if form.is_valid(): + post.title = form.cleaned_data['title'] + post.content = form.cleaned_data['content'] + post.active = False + post.save() + return redirect('contributions') + else: + form = CreatePost(initial={ + 'title': post.title, + 'content': post.content, + }) + + return render(request, 'posts/edit_post.html', {'form': form, 'post': post}) + +@groups_required('Administrateur', 'Super Admin') +def prending_posts(request): + posts = Post.objects.filter(active=False) + + return render(request, 'posts/pending_posts.html') \ No newline at end of file diff --git a/quiz/__init__.py b/quiz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quiz/admin.py b/quiz/admin.py new file mode 100644 index 0000000..576cd55 --- /dev/null +++ b/quiz/admin.py @@ -0,0 +1,25 @@ +from django.contrib import admin +from django.db.models import QuerySet +from .models import * + +@admin.action(description="Activer les quizes séléctionnés") +def activate(modelAdmin, request, querySet: QuerySet): + updated = querySet.update(is_active=True) + modelAdmin.message_user(request, f"{updated} quiz(es) ont été activé(s).") + +@admin.action(description="Désactiver les quizes séléctionnés") +def deactivate(modelAdmin, request, querySet: QuerySet): + updated = querySet.update(is_active=False) + modelAdmin.message_user(request, f"{updated} quiz(es) ont été désactivé(s).") + +class QuizAdmin(admin.ModelAdmin): + list_display = ('name', 'author', 'is_active', 'created', 'updated') + list_filter = ('author', 'is_active', 'created', 'updated') + search_fields = ('title', 'author__username') + ordering = ('-created',) + fields = ('title', 'author', 'is_active') + actions = [activate, deactivate] + +admin.site.register(Quiz, QuizAdmin) +admin.site.register(Question) +admin.site.register(Choice) diff --git a/quiz/apps.py b/quiz/apps.py new file mode 100644 index 0000000..3dc8afe --- /dev/null +++ b/quiz/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class QuizConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'quiz' diff --git a/quiz/context_processors.py b/quiz/context_processors.py new file mode 100644 index 0000000..4846516 --- /dev/null +++ b/quiz/context_processors.py @@ -0,0 +1,7 @@ +from .models import Quiz +from users.decorators import groups_required + +groups_required('Administrateur', 'SuperAdmin') +def pending_quizes_count(request): + count = Quiz.objects.filter(is_active=False).count() + return {'pending_quizes_count': count} \ No newline at end of file diff --git a/quiz/forms.py b/quiz/forms.py new file mode 100644 index 0000000..d3752ae --- /dev/null +++ b/quiz/forms.py @@ -0,0 +1,27 @@ +from django import forms +from .models import * + +# Gestion des formulaires pour les quiz en se servant du model +class QuizForm(forms.ModelForm): + class Meta: + model = Quiz + fields = ['name', 'description', 'image'] + labels = { + 'name': '', + 'description': '', + 'image': '(Pas obligatoire) ' + } + widgets = { + 'name': forms.TextInput(attrs={'placeholder': 'Nom du quiz'}), + 'description': forms.Textarea(attrs={'placeholder': 'Un text de description pour que les membres aient une idée du contenu du quiz'}), + } + +class QuestionForm(forms.ModelForm): + class Meta: + model = Question + fields = ['question'] + +class ChoiceForm(forms.ModelForm): + class Meta: + model = Choice + fields = ['choice', 'is_correct'] \ No newline at end of file diff --git a/quiz/migrations/0001_initial.py b/quiz/migrations/0001_initial.py new file mode 100644 index 0000000..449edbf --- /dev/null +++ b/quiz/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 4.2.17 on 2025-01-04 13:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Quiz', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=155)), + ('description', models.TextField(default='')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('is_active', models.BooleanField(default=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserQuiz', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('score', models.IntegerField(default=0)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_quizzes', to='quiz.quiz')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_quizzes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('question', models.CharField(max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.quiz')), + ], + ), + migrations.CreateModel( + name='Choice', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('choice', models.CharField(max_length=255)), + ('is_correct', models.BooleanField(default=False)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='quiz.question')), + ], + ), + migrations.CreateModel( + name='UserAnswer', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('choice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_answers', to='quiz.choice')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_answers', to='quiz.question')), + ('user_quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_answers', to='quiz.userquiz')), + ], + options={ + 'unique_together': {('user_quiz', 'question')}, + }, + ), + ] diff --git a/quiz/migrations/0002_quiz_image.py b/quiz/migrations/0002_quiz_image.py new file mode 100644 index 0000000..7f24306 --- /dev/null +++ b/quiz/migrations/0002_quiz_image.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.17 on 2025-01-04 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('quiz', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='quiz', + name='image', + field=models.ImageField(blank=True, default='', upload_to='quiz_images/'), + ), + ] diff --git a/quiz/migrations/__init__.py b/quiz/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quiz/models.py b/quiz/models.py new file mode 100644 index 0000000..50523f6 --- /dev/null +++ b/quiz/models.py @@ -0,0 +1,57 @@ +from django.db import models +from users.models import User + +class Quiz(models.Model): + id = models.AutoField(primary_key=True) + author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='quizzes') + name = models.CharField(max_length=155) + description = models.TextField(default="") + image = models.ImageField(upload_to='quiz_images/', default='', blank=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name + +class Question(models.Model): + id = models.AutoField(primary_key=True) + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='questions') + question = models.CharField(max_length=255) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.question + +class Choice(models.Model): # Renommé au singulier + id = models.AutoField(primary_key=True) + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices') + choice = models.CharField(max_length=255) + is_correct = models.BooleanField(default=False) + + def __str__(self): + return self.choice + +class UserQuiz(models.Model): + id = models.AutoField(primary_key=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_quizzes') + quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name='user_quizzes') # Ajouté + score = models.IntegerField(default=0) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.user.username} - {self.quiz.name}" + +class UserAnswer(models.Model): + id = models.AutoField(primary_key=True) + user_quiz = models.ForeignKey(UserQuiz, on_delete=models.CASCADE, related_name='user_answers') + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='user_answers') + choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='user_answers') + + def __str__(self): + return f"{self.user_quiz.user.username} - {self.question.question}" + + class Meta: + unique_together = ('user_quiz', 'question') # Empêche qu'un utilisateur réponde plusieurs fois à la même question. \ No newline at end of file diff --git a/quiz/templatetags/quiz_tags.py b/quiz/templatetags/quiz_tags.py new file mode 100644 index 0000000..8310daf --- /dev/null +++ b/quiz/templatetags/quiz_tags.py @@ -0,0 +1,8 @@ + +from django import template + +register = template.Library() + +@register.filter +def get_item(dictionary, key): + return dictionary.get(key) \ No newline at end of file diff --git a/quiz/tests.py b/quiz/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/quiz/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/quiz/urls.py b/quiz/urls.py new file mode 100644 index 0000000..a58871e --- /dev/null +++ b/quiz/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.home, name='quiz_home'), + path('/', views.quiz, name='quiz'), + path('result//', views.result, name='quiz_result'), + path('create/', views.create_quiz, name='create_quiz'), + path('create//', views.create_responses_quiz, name='create_responses_quiz'), +] \ No newline at end of file diff --git a/quiz/views.py b/quiz/views.py new file mode 100644 index 0000000..b44f0a9 --- /dev/null +++ b/quiz/views.py @@ -0,0 +1,149 @@ +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from .models import * +from users.models import UserLevel, UserInventory +from shop.models import Item +from .forms import * + +@login_required +def home(request): + quizes = Quiz.objects.filter(is_active=True) + my_quizes = Quiz.objects.filter(author=request.user) + + if request.user.is_superuser: + quizes = Quiz.objects.all() + + + # Récupérer les meilleurs scores de l'utilisateur pour chaque quiz + user_scores = {} + if request.user.is_authenticated: + user_quiz_scores = UserQuiz.objects.filter( + user=request.user + ).values('quiz_id').annotate( + best_score=models.Max('score') + ) + user_scores = {score['quiz_id']: score['best_score'] for score in user_quiz_scores} + + return render(request, "games/quiz/home.html", { + "quizes": quizes, + "my_quizes": my_quizes, + "user_scores": user_scores + }) + +@login_required +def quiz(request, quiz_id): + quiz = get_object_or_404(Quiz, id=quiz_id) + + if request.method == 'POST': + # Vérifier si l'utilisateur a déjà participé à ce quiz + user_quiz = UserQuiz.objects.filter(user=request.user, quiz=quiz).first() + if user_quiz: + # Si oui, supprimer les anciennes réponses + UserAnswer.objects.filter(user_quiz=user_quiz).delete() + else: + # Si non, créer une nouvelle entrée + user_quiz = UserQuiz.objects.create(user=request.user, quiz=quiz) + + score = 0 + for question in quiz.questions.all(): + choice_id = request.POST.get(f'question_{question.id}') + if choice_id: + choice = Choice.objects.get(id=choice_id) + UserAnswer.objects.create( + user_quiz=user_quiz, + question=question, + choice=choice + ) + if choice.is_correct: + score += 1 + + user_quiz.score = score + user_quiz.save() + return redirect('quiz_result', user_quiz_id=user_quiz.id) + + return render(request, "games/quiz/quiz.html", { + "quiz": quiz, + "questions": quiz.questions.all() + }) + +@login_required +def result(request, user_quiz_id): + user_quiz = get_object_or_404(UserQuiz, id=user_quiz_id, user=request.user) + total_questions = user_quiz.quiz.questions.count() + + # Vérifiez si l'utilisateur a déjà joué à ce quiz + if not UserQuiz.objects.filter(user=request.user, quiz=user_quiz.quiz).exists(): + # Si c'est la première fois que l'on joue, alors on gagne 10 pièces d'or + 10 points d'expérience par réponse correcte + inventory_item = UserInventory.objects.get(user=user_quiz.user, item=Item.objects.get(name='Or')) + inventory_item.quantity += 10 + inventory_item.save() + + user_level = request.user.level + user_level.experience += 10 * user_quiz.score + user_level.save() + else: + # Si l'utilisateur a déjà joué, ajoutez 1 point d'expérience par réponse correcte + user_level = request.user.level + user_level.experience += 1 * user_quiz.score + user_level.save() + + return render(request, "games/quiz/result.html", { + "user_quiz": user_quiz, + "total_questions": total_questions, + "percentage": (user_quiz.score / total_questions) * 100 if total_questions > 0 else 0 + }) + +@login_required +def create_quiz(request): + form = QuizForm() + if request.method == 'POST': + quiz = Quiz.objects.create(author=request.user, name=request.POST.get('name'), description=request.POST.get('description'), is_active=False) + return redirect('create_responses_quiz', quiz_id=quiz.id) + + return render(request, "games/quiz/create_quiz.html", {"form": form}) + +@login_required +def create_responses_quiz(request, quiz_id): + quiz = get_object_or_404(Quiz, id=quiz_id, author=request.user) + questions = quiz.questions.all() + choices = [] + for question in questions: + choices.append(question.choices.all()) + + form = QuestionForm() + + if request.method == 'POST': + # Parcourir les données POST pour trouver les questions et réponses + for key in request.POST: + if key.startswith('ask-'): + question_text = request.POST[key] + if question_text: # Vérifie si la question n'est pas vide + # Créer la question + question = Question.objects.create( + quiz=quiz, + question=question_text + ) + + # Récupérer le numéro de la question depuis la clé + question_num = key.split('-')[1] + + # Chercher toutes les réponses associées à cette question + response_prefix = f'response-{question_num}-' + correct_response_key = f'is_correct-{question_num}' + correct_response_value = request.POST.get(correct_response_key) + + for response_key in request.POST: + if response_key.startswith(response_prefix): + response_text = request.POST[response_key] + if response_text: # Vérifie si la réponse n'est pas vide + response_num = response_key.split('-')[-1] + is_correct = (response_num == correct_response_value) + Choice.objects.create( + question=question, + choice=response_text, + is_correct=is_correct # Par défaut, aucune réponse n'est correcte + ) + + return redirect('create_responses_quiz', quiz.id) # Redirection vers la page d'accueil + + return render(request, "games/quiz/create_responses_quiz.html", {"form": form, "quiz": quiz}) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c359d0d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +django +Pillow +python-dotenv +pymysql \ No newline at end of file diff --git a/shop/__init__.py b/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/admin.py b/shop/admin.py new file mode 100644 index 0000000..f3edd38 --- /dev/null +++ b/shop/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from .models import Category, Item + +# Register your models here. +admin.site.register(Category) +admin.site.register(Item) \ No newline at end of file diff --git a/shop/apps.py b/shop/apps.py new file mode 100644 index 0000000..1f05a2b --- /dev/null +++ b/shop/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'shop' diff --git a/shop/migrations/0001_initial.py b/shop/migrations/0001_initial.py new file mode 100644 index 0000000..4395954 --- /dev/null +++ b/shop/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.17 on 2025-01-07 10:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField()), + ('image', models.ImageField(upload_to='items_categories/')), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField()), + ('price', models.DecimalField(decimal_places=2, max_digits=5)), + ('image', models.ImageField(upload_to='items/')), + ('is_active', models.BooleanField(default=True)), + ], + ), + ] diff --git a/shop/migrations/0002_item_category_alter_item_image.py b/shop/migrations/0002_item_category_alter_item_image.py new file mode 100644 index 0000000..747192c --- /dev/null +++ b/shop/migrations/0002_item_category_alter_item_image.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.17 on 2025-01-07 13:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='category', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='shop.category'), + preserve_default=False, + ), + migrations.AlterField( + model_name='item', + name='image', + field=models.ImageField(upload_to='shop/items/'), + ), + ] diff --git a/shop/migrations/__init__.py b/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/models.py b/shop/models.py new file mode 100644 index 0000000..e32da2a --- /dev/null +++ b/shop/models.py @@ -0,0 +1,22 @@ +from django.db import models + +# Create your models here. +class Item(models.Model): + category = models.ForeignKey('Category', on_delete=models.CASCADE, related_name='items') + name = models.CharField(max_length=100) + description = models.TextField() + price = models.DecimalField(max_digits=5, decimal_places=2) + image = models.ImageField(upload_to='shop/items/') + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name + +class Category(models.Model): + name = models.CharField(max_length=100) + description = models.TextField() + image = models.ImageField(upload_to='items_categories/') + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/shop/tests.py b/shop/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/shop/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/shop/urls.py b/shop/urls.py new file mode 100644 index 0000000..e00c0b7 --- /dev/null +++ b/shop/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.home, name='shop_home'), + path('buy//', views.buy, name='shop_buy'), +] \ No newline at end of file diff --git a/shop/views.py b/shop/views.py new file mode 100644 index 0000000..add60e9 --- /dev/null +++ b/shop/views.py @@ -0,0 +1,36 @@ +from django.shortcuts import render, redirect +from .models import * +from users.models import UserInventory +from django.contrib.auth.decorators import login_required +from django.contrib import messages + +# Create your views here. +def home(request): + items = Item.objects.filter(is_active=True) + print(items) + return render(request, 'shop/home.html', {'items': items}) + +@login_required +def buy(request, item_id): + item = Item.objects.get(id=item_id) + # ON vérifie si l'utilisateur a assez d'argent pour acheter l'item + if request.user.money >= item.price: + # On vérifie que l'utilisateur n'a pas déjà acheté l'item + if not request.user.inventory.filter(item=item).exists(): + # On déduit le prix de l'item de l'argent de l'utilisateur + user_inventory = UserInventory.objects.get(user=request.user, item__name='Or') + user_inventory.quantity -= item.price + user_inventory.save() + + # On ajoute l'item dans l'inventaire de l'utilisateur + user_inventory = UserInventory.objects.create(user=request.user, item=item) + user_inventory.quantity = 1 + user_inventory.save() + messages.success(request, f"Vous avez acheté {item.name} pour {item.price} pièces d'or.") + else: + messages.error(request, f"Vous avez déjà acheté {item.name}.") + else: + messages.error(request, f"Vous n'avez pas assez d'argent pour acheter {item.name}.") + return redirect('shop_home') + + \ No newline at end of file diff --git a/static/css/autocomplete.css b/static/css/autocomplete.css new file mode 100644 index 0000000..69c94e7 --- /dev/null +++ b/static/css/autocomplete.css @@ -0,0 +1,275 @@ +select.admin-autocomplete { + width: 20em; +} + +.select2-container--admin-autocomplete.select2-container { + min-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single, +.select2-container--admin-autocomplete .select2-selection--multiple { + min-height: 30px; + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection, +.select2-container--admin-autocomplete.select2-container--open .select2-selection { + border-color: var(--body-quiet-color); + min-height: 30px; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--single, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--single { + padding: 0; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection.select2-selection--multiple, +.select2-container--admin-autocomplete.select2-container--open .select2-selection.select2-selection--multiple { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-selection--single { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__rendered { + color: var(--body-fg); + line-height: 30px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__placeholder { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; +} + +.select2-container--admin-autocomplete .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple { + background-color: var(--body-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: text; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 10px 5px 5px; + width: 100%; + display: flex; + flex-wrap: wrap; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__rendered li { + list-style: none; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__placeholder { + color: var(--body-quiet-color); + margin-top: 5px; + float: left; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin: 5px; + position: absolute; + right: 0; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice { + background-color: var(--darkened-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove { + color: var(--body-quiet-color); + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; +} + +.select2-container--admin-autocomplete .select2-selection--multiple .select2-selection__choice__remove:hover { + color: var(--body-fg); +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder, .select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; +} + +.select2-container--admin-autocomplete[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; +} + +.select2-container--admin-autocomplete.select2-container--focus .select2-selection--multiple { + border: solid var(--body-quiet-color) 1px; + outline: 0; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection--multiple { + background-color: var(--darkened-bg); + cursor: default; +} + +.select2-container--admin-autocomplete.select2-container--disabled .select2-selection__choice__remove { + display: none; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--single, .select2-container--admin-autocomplete.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.select2-container--admin-autocomplete .select2-search--dropdown { + background: var(--darkened-bg); +} + +.select2-container--admin-autocomplete .select2-search--dropdown .select2-search__field { + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; +} + +.select2-container--admin-autocomplete .select2-search--inline .select2-search__field { + background: transparent; + color: var(--body-fg); + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; +} + +.select2-container--admin-autocomplete .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; + color: var(--body-fg); + background: var(--body-bg); +} + +.select2-container--admin-autocomplete .select2-results__option[role=group] { + padding: 0; +} + +.select2-container--admin-autocomplete .select2-results__option[aria-disabled=true] { + color: var(--body-quiet-color); +} + +.select2-container--admin-autocomplete .select2-results__option[aria-selected=true] { + background-color: var(--selected-bg); + color: var(--body-fg); +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option { + padding-left: 1em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; +} + +.select2-container--admin-autocomplete .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; +} + +.select2-container--admin-autocomplete .select2-results__option--highlighted[aria-selected] { + background-color: var(--primary); + color: var(--primary-fg); +} + +.select2-container--admin-autocomplete .select2-results__group { + cursor: default; + display: block; + padding: 6px; +} diff --git a/static/css/base.css b/static/css/base.css new file mode 100644 index 0000000..93db7d0 --- /dev/null +++ b/static/css/base.css @@ -0,0 +1,1145 @@ +/* + DJANGO Admin styles +*/ + +/* VARIABLE DEFINITIONS */ +html[data-theme="light"], +:root { + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); + --breadcrumbs-bg: var(--primary); + + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-size: 0.875rem; + font-family: var(--font-family-primary); + color: var(--body-fg); + background: var(--body-bg); +} + +/* LINKS */ + +a:link, a:visited { + color: var(--link-fg); + text-decoration: none; + transition: color 0.15s, background 0.15s; +} + +a:focus, a:hover { + color: var(--link-hover-color); +} + +a:focus { + text-decoration: underline; +} + +a img { + border: none; +} + +a.section:link, a.section:visited { + color: var(--header-link-color); + text-decoration: none; +} + +a.section:focus, a.section:hover { + text-decoration: underline; +} + +/* GLOBAL DEFAULTS */ + +p, ol, ul, dl { + margin: .2em 0 .8em 0; +} + +p { + padding: 0; + line-height: 140%; +} + +h1,h2,h3,h4,h5 { + font-weight: bold; +} + +h1 { + margin: 0 0 20px; + font-weight: 300; + font-size: 1.25rem; + color: var(--body-quiet-color); +} + +h2 { + font-size: 1rem; + margin: 1em 0 .5em 0; +} + +h2.subhead { + font-weight: normal; + margin-top: 0; +} + +h3 { + font-size: 0.875rem; + margin: .8em 0 .3em 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +h4 { + font-size: 0.75rem; + margin: 1em 0 .8em 0; + padding-bottom: 3px; +} + +h5 { + font-size: 0.625rem; + margin: 1.5em 0 .5em 0; + color: var(--body-quiet-color); + text-transform: uppercase; + letter-spacing: 1px; +} + +ul > li { + list-style-type: square; + padding: 1px 0; +} + +li ul { + margin-bottom: 0; +} + +li, dt, dd { + font-size: 0.8125rem; + line-height: 1.25rem; +} + +dt { + font-weight: bold; + margin-top: 4px; +} + +dd { + margin-left: 0; +} + +form { + margin: 0; + padding: 0; +} + +fieldset { + margin: 0; + min-width: 0; + padding: 0; + border: none; + border-top: 1px solid var(--hairline-color); +} + +blockquote { + font-size: 0.6875rem; + color: #777; + margin-left: 2px; + padding-left: 10px; + border-left: 5px solid #ddd; +} + +code, pre { + font-family: var(--font-family-monospace); + color: var(--body-quiet-color); + font-size: 0.75rem; + overflow-x: auto; +} + +pre.literal-block { + margin: 10px; + background: var(--darkened-bg); + padding: 6px 8px; +} + +code strong { + color: #930; +} + +hr { + clear: both; + color: var(--hairline-color); + background-color: var(--hairline-color); + height: 1px; + border: none; + margin: 0; + padding: 0; + line-height: 1px; +} + +/* TEXT STYLES & MODIFIERS */ + +.small { + font-size: 0.6875rem; +} + +.mini { + font-size: 0.625rem; +} + +.help, p.help, form p.help, div.help, form div.help, div.help li { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +div.help ul { + margin-bottom: 0; +} + +.help-tooltip { + cursor: help; +} + +p img, h1 img, h2 img, h3 img, h4 img, td img { + vertical-align: middle; +} + +.quiet, a.quiet:link, a.quiet:visited { + color: var(--body-quiet-color); + font-weight: normal; +} + +.clear { + clear: both; +} + +.nowrap { + white-space: nowrap; +} + +.hidden { + display: none !important; +} + +/* TABLES */ + +table { + border-collapse: collapse; + border-color: var(--border-color); +} + +td, th { + font-size: 0.8125rem; + line-height: 1rem; + border-bottom: 1px solid var(--hairline-color); + vertical-align: top; + padding: 8px; +} + +th { + font-weight: 600; + text-align: left; +} + +thead th, +tfoot td { + color: var(--body-quiet-color); + padding: 5px 10px; + font-size: 0.6875rem; + background: var(--body-bg); + border: none; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +tfoot td { + border-bottom: none; + border-top: 1px solid var(--hairline-color); +} + +thead th.required { + color: var(--body-loud-color); +} + +tr.alt { + background: var(--darkened-bg); +} + +tr:nth-child(odd), .row-form-errors { + background: var(--body-bg); +} + +tr:nth-child(even), +tr:nth-child(even) .errorlist, +tr:nth-child(odd) + .row-form-errors, +tr:nth-child(odd) + .row-form-errors .errorlist { + background: var(--darkened-bg); +} + +/* SORTABLE TABLES */ + +thead th { + padding: 5px 10px; + line-height: normal; + text-transform: uppercase; + background: var(--darkened-bg); +} + +thead th a:link, thead th a:visited { + color: var(--body-quiet-color); +} + +thead th.sorted { + background: var(--selected-bg); +} + +thead th.sorted .text { + padding-right: 42px; +} + +table thead th .text span { + padding: 8px 10px; + display: block; +} + +table thead th .text a { + display: block; + cursor: pointer; + padding: 8px 10px; +} + +table thead th .text a:focus, table thead th .text a:hover { + background: var(--selected-bg); +} + +thead th.sorted a.sortremove { + visibility: hidden; +} + +table thead th.sorted:hover a.sortremove { + visibility: visible; +} + +table thead th.sorted .sortoptions { + display: block; + padding: 9px 5px 0 5px; + float: right; + text-align: right; +} + +table thead th.sorted .sortpriority { + font-size: .8em; + min-width: 12px; + text-align: center; + vertical-align: 3px; + margin-left: 2px; + margin-right: 2px; +} + +table thead th.sorted .sortoptions a { + position: relative; + width: 14px; + height: 14px; + display: inline-block; + background: url(../img/sorting-icons.svg) 0 0 no-repeat; + background-size: 14px auto; +} + +table thead th.sorted .sortoptions a.sortremove { + background-position: 0 0; +} + +table thead th.sorted .sortoptions a.sortremove:after { + content: '\\'; + position: absolute; + top: -6px; + left: 3px; + font-weight: 200; + font-size: 1.125rem; + color: var(--body-quiet-color); +} + +table thead th.sorted .sortoptions a.sortremove:focus:after, +table thead th.sorted .sortoptions a.sortremove:hover:after { + color: var(--link-fg); +} + +table thead th.sorted .sortoptions a.sortremove:focus, +table thead th.sorted .sortoptions a.sortremove:hover { + background-position: 0 -14px; +} + +table thead th.sorted .sortoptions a.ascending { + background-position: 0 -28px; +} + +table thead th.sorted .sortoptions a.ascending:focus, +table thead th.sorted .sortoptions a.ascending:hover { + background-position: 0 -42px; +} + +table thead th.sorted .sortoptions a.descending { + top: 1px; + background-position: 0 -56px; +} + +table thead th.sorted .sortoptions a.descending:focus, +table thead th.sorted .sortoptions a.descending:hover { + background-position: 0 -70px; +} + +/* FORM DEFAULTS */ + +input, textarea, select, .form-row p, form .button { + margin: 2px 0; + padding: 2px 3px; + vertical-align: middle; + font-family: var(--font-family-primary); + font-weight: normal; + font-size: 0.8125rem; +} +.form-row div.help { + padding: 2px 3px; +} + +textarea { + vertical-align: top; +} + +input[type=text], input[type=password], input[type=email], input[type=url], +input[type=number], input[type=tel], textarea, select, .vTextField { + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 5px 6px; + margin-top: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} + +input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, +input[type=url]:focus, input[type=number]:focus, input[type=tel]:focus, +textarea:focus, select:focus, .vTextField:focus { + border-color: var(--body-quiet-color); +} + +select { + height: 1.875rem; +} + +select[multiple] { + /* Allow HTML size attribute to override the height in the rule above. */ + height: auto; + min-height: 150px; +} + +/* FORM BUTTONS */ + +.button, input[type=submit], input[type=button], .submit-row input, a.button { + background: var(--button-bg); + padding: 10px 15px; + border: none; + border-radius: 4px; + color: var(--button-fg); + cursor: pointer; + transition: background 0.15s; +} + +a.button { + padding: 4px 5px; +} + +.button:active, input[type=submit]:active, input[type=button]:active, +.button:focus, input[type=submit]:focus, input[type=button]:focus, +.button:hover, input[type=submit]:hover, input[type=button]:hover { + background: var(--button-hover-bg); +} + +.button[disabled], input[type=submit][disabled], input[type=button][disabled] { + opacity: 0.4; +} + +.button.default, input[type=submit].default, .submit-row input.default { + border: none; + font-weight: 400; + background: var(--default-button-bg); +} + +.button.default:active, input[type=submit].default:active, +.button.default:focus, input[type=submit].default:focus, +.button.default:hover, input[type=submit].default:hover { + background: var(--default-button-hover-bg); +} + +.button[disabled].default, +input[type=submit][disabled].default, +input[type=button][disabled].default { + opacity: 0.4; +} + + +/* MODULES */ + +.module { + border: none; + margin-bottom: 30px; + background: var(--body-bg); +} + +.module p, .module ul, .module h3, .module h4, .module dl, .module pre { + padding-left: 10px; + padding-right: 10px; +} + +.module blockquote { + margin-left: 12px; +} + +.module ul, .module ol { + margin-left: 1.5em; +} + +.module h3 { + margin-top: .6em; +} + +.module h2, .module caption, .inline-group h2 { + margin: 0; + padding: 8px; + font-weight: 400; + font-size: 0.8125rem; + text-align: left; + background: var(--primary); + color: var(--header-link-color); +} + +.module caption, +.inline-group h2 { + font-size: 0.75rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.module table { + border-collapse: collapse; +} + +/* MESSAGES & ERRORS */ + +ul.messagelist { + padding: 0; + margin: 0; +} + +ul.messagelist li { + display: block; + font-weight: 400; + font-size: 0.8125rem; + padding: 10px 10px 10px 65px; + margin: 0 0 10px 0; + background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; + background-size: 16px auto; + color: var(--body-fg); + word-break: break-word; +} + +ul.messagelist li.warning { + background: var(--message-warning-bg) url(../img/icon-alert.svg) 40px 14px no-repeat; + background-size: 14px auto; +} + +ul.messagelist li.error { + background: var(--message-error-bg) url(../img/icon-no.svg) 40px 12px no-repeat; + background-size: 16px auto; +} + +.errornote { + font-size: 0.875rem; + font-weight: 700; + display: block; + padding: 10px 12px; + margin: 0 0 10px 0; + color: var(--error-fg); + border: 1px solid var(--error-fg); + border-radius: 4px; + background-color: var(--body-bg); + background-position: 5px 12px; + overflow-wrap: break-word; +} + +ul.errorlist { + margin: 0 0 4px; + padding: 0; + color: var(--error-fg); + background: var(--body-bg); +} + +ul.errorlist li { + font-size: 0.8125rem; + display: block; + margin-bottom: 4px; + overflow-wrap: break-word; +} + +ul.errorlist li:first-child { + margin-top: 0; +} + +ul.errorlist li a { + color: inherit; + text-decoration: underline; +} + +td ul.errorlist { + margin: 0; + padding: 0; +} + +td ul.errorlist li { + margin: 0; +} + +.form-row.errors { + margin: 0; + border: none; + border-bottom: 1px solid var(--hairline-color); + background: none; +} + +.form-row.errors ul.errorlist li { + padding-left: 0; +} + +.errors input, .errors select, .errors textarea, +td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { + border: 1px solid var(--error-fg); +} + +.description { + font-size: 0.75rem; + padding: 5px 0 0 12px; +} + +/* BREADCRUMBS */ + +div.breadcrumbs { + background: var(--breadcrumbs-bg); + padding: 10px 40px; + border: none; + color: var(--breadcrumbs-fg); + text-align: left; +} + +div.breadcrumbs a { + color: var(--breadcrumbs-link-fg); +} + +div.breadcrumbs a:focus, div.breadcrumbs a:hover { + color: var(--breadcrumbs-fg); +} + +/* ACTION ICONS */ + +.viewlink, .inlineviewlink { + padding-left: 16px; + background: url(../img/icon-viewlink.svg) 0 1px no-repeat; +} + +.addlink { + padding-left: 16px; + background: url(../img/icon-addlink.svg) 0 1px no-repeat; +} + +.changelink, .inlinechangelink { + padding-left: 16px; + background: url(../img/icon-changelink.svg) 0 1px no-repeat; +} + +.deletelink { + padding-left: 16px; + background: url(../img/icon-deletelink.svg) 0 1px no-repeat; +} + +a.deletelink:link, a.deletelink:visited { + color: #CC3434; /* XXX Probably unused? */ +} + +a.deletelink:focus, a.deletelink:hover { + color: #993333; /* XXX Probably unused? */ + text-decoration: none; +} + +/* OBJECT TOOLS */ + +.object-tools { + font-size: 0.625rem; + font-weight: bold; + padding-left: 0; + float: right; + position: relative; + margin-top: -48px; +} + +.object-tools li { + display: block; + float: left; + margin-left: 5px; + height: 1rem; +} + +.object-tools a { + border-radius: 15px; +} + +.object-tools a:link, .object-tools a:visited { + display: block; + float: left; + padding: 3px 12px; + background: var(--object-tools-bg); + color: var(--object-tools-fg); + font-weight: 400; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.object-tools a:focus, .object-tools a:hover { + background-color: var(--object-tools-hover-bg); +} + +.object-tools a:focus{ + text-decoration: none; +} + +.object-tools a.viewsitelink, .object-tools a.addlink { + background-repeat: no-repeat; + background-position: right 7px center; + padding-right: 26px; +} + +.object-tools a.viewsitelink { + background-image: url(../img/tooltag-arrowright.svg); +} + +.object-tools a.addlink { + background-image: url(../img/tooltag-add.svg); +} + +/* OBJECT HISTORY */ + +#change-history table { + width: 100%; +} + +#change-history table tbody th { + width: 16em; +} + +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* PAGE STRUCTURE */ + +#container { + position: relative; + width: 100%; + min-width: 980px; + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +#container > div { + flex-shrink: 0; +} + +#container > .main { + display: flex; + flex: 1 0 auto; +} + +.main > .content { + flex: 1 0; + max-width: 100%; +} + +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + +#content { + padding: 20px 40px; +} + +.dashboard #content { + width: 600px; +} + +#content-main { + float: left; + width: 100%; +} + +#content-related { + float: right; + width: 260px; + position: relative; + margin-right: -300px; +} + +#footer { + clear: both; + padding: 10px; +} + +/* COLUMN TYPES */ + +.colMS { + margin-right: 300px; +} + +.colSM { + margin-left: 300px; +} + +.colSM #content-related { + float: left; + margin-right: 0; + margin-left: -300px; +} + +.colSM #content-main { + float: right; +} + +.popup .colM { + width: auto; +} + +/* HEADER */ + +#header { + width: auto; + height: auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 40px; + background: var(--header-bg); + color: var(--header-color); + overflow: hidden; +} + +#header a:link, #header a:visited, #logout-form button { + color: var(--header-link-color); +} + +#header a:focus , #header a:hover { + text-decoration: underline; +} + +#branding { + display: flex; +} + +#branding h1 { + padding: 0; + margin: 0; + margin-inline-end: 20px; + font-weight: 300; + font-size: 1.5rem; + color: var(--header-branding-color); +} + +#branding h1 a:link, #branding h1 a:visited { + color: var(--accent); +} + +#branding h2 { + padding: 0 10px; + font-size: 0.875rem; + margin: -8px 0 8px 0; + font-weight: normal; + color: var(--header-color); +} + +#branding a:hover { + text-decoration: none; +} + +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + +#user-tools { + float: right; + margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; + font-weight: 300; + font-size: 0.6875rem; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +#user-tools a, #logout-form button { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); +} + +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { + text-decoration: none; + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; +} + +/* SIDEBAR */ + +#content-related { + background: var(--darkened-bg); +} + +#content-related .module { + background: none; +} + +#content-related h3 { + color: var(--body-quiet-color); + padding: 0 16px; + margin: 0 0 16px; +} + +#content-related h4 { + font-size: 0.8125rem; +} + +#content-related p { + padding-left: 16px; + padding-right: 16px; +} + +#content-related .actionlist { + padding: 0; + margin: 16px; +} + +#content-related .actionlist li { + line-height: 1.2; + margin-bottom: 10px; + padding-left: 18px; +} + +#content-related .module h2 { + background: none; + padding: 16px; + margin-bottom: 16px; + border-bottom: 1px solid var(--hairline-color); + font-size: 1.125rem; + color: var(--body-fg); +} + +.delete-confirmation form input[type="submit"] { + background: var(--delete-button-bg); + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); +} + +.delete-confirmation form input[type="submit"]:active, +.delete-confirmation form input[type="submit"]:focus, +.delete-confirmation form input[type="submit"]:hover { + background: var(--delete-button-hover-bg); +} + +.delete-confirmation form .cancel-link { + display: inline-block; + vertical-align: middle; + height: 0.9375rem; + line-height: 0.9375rem; + border-radius: 4px; + padding: 10px 15px; + color: var(--button-fg); + background: var(--close-button-bg); + margin: 0 0 0 10px; +} + +.delete-confirmation form .cancel-link:active, +.delete-confirmation form .cancel-link:focus, +.delete-confirmation form .cancel-link:hover { + background: var(--close-button-hover-bg); +} + +/* POPUP */ +.popup #content { + padding: 20px; +} + +.popup #container { + min-width: 0; +} + +.popup #header { + padding: 10px 20px; +} + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} diff --git a/static/css/changelists.css b/static/css/changelists.css new file mode 100644 index 0000000..a754513 --- /dev/null +++ b/static/css/changelists.css @@ -0,0 +1,328 @@ +/* CHANGELISTS */ + +#changelist { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +#changelist .changelist-form-container { + flex: 1 1 auto; + min-width: 0; +} + +#changelist table { + width: 100%; +} + +.change-list .hiddenfields { display:none; } + +.change-list .filtered table { + border-right: none; +} + +.change-list .filtered { + min-height: 400px; +} + +.change-list .filtered .results, .change-list .filtered .paginator, +.filtered #toolbar, .filtered div.xfull { + width: auto; +} + +.change-list .filtered table tbody th { + padding-right: 1em; +} + +#changelist-form .results { + overflow-x: auto; + width: 100%; +} + +#changelist .toplinks { + border-bottom: 1px solid var(--hairline-color); +} + +#changelist .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + +/* CHANGELIST TABLES */ + +#changelist table thead th { + padding: 0; + white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th.action-checkbox-column { + width: 1.5em; + text-align: center; +} + +#changelist table tbody td.action-checkbox { + text-align: center; +} + +#changelist table tfoot { + color: var(--body-quiet-color); +} + +/* TOOLBAR */ + +#toolbar { + padding: 8px 10px; + margin-bottom: 15px; + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +#toolbar form input { + border-radius: 4px; + font-size: 0.875rem; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 1.1875rem; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 0.8125rem; + max-width: 100%; +} + +#toolbar #searchbar:focus { + border-color: var(--body-quiet-color); +} + +#toolbar form input[type="submit"] { + border: 1px solid var(--border-color); + font-size: 0.8125rem; + padding: 4px 8px; + margin: 0; + vertical-align: middle; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + color: var(--body-fg); +} + +#toolbar form input[type="submit"]:focus, +#toolbar form input[type="submit"]:hover { + border-color: var(--body-quiet-color); +} + +#changelist-search img { + vertical-align: middle; + margin-right: 4px; +} + +#changelist-search .help { + word-break: break-word; +} + +/* FILTER COLUMN */ + +#changelist-filter { + flex: 0 0 240px; + order: 1; + background: var(--darkened-bg); + border-left: none; + margin: 0 0 0 30px; +} + +#changelist-filter h2 { + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3, +#changelist-filter details summary { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + +#changelist-filter ul { + margin: 5px 0; + padding: 0 15px 15px; + border-bottom: 1px solid var(--hairline-color); +} + +#changelist-filter ul:last-child { + border-bottom: none; +} + +#changelist-filter li { + list-style-type: none; + margin-left: 0; + padding-left: 0; +} + +#changelist-filter a { + display: block; + color: var(--body-quiet-color); + word-break: break-word; +} + +#changelist-filter li.selected { + border-left: 5px solid var(--hairline-color); + padding-left: 10px; + margin-left: -15px; +} + +#changelist-filter li.selected a { + color: var(--link-selected-fg); +} + +#changelist-filter a:focus, #changelist-filter a:hover, +#changelist-filter li.selected a:focus, +#changelist-filter li.selected a:hover { + color: var(--link-hover-color); +} + +#changelist-filter #changelist-filter-clear a { + font-size: 0.8125rem; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; + font-weight: bold; +} + +.change-list .toplinks a { + font-size: 0.8125rem; +} + +.change-list .toplinks .date-back { + color: var(--body-quiet-color); +} + +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { + color: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { + background-color: var(--selected-row); +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 1.5rem; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 0.8125rem; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 1.5rem; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + padding: 0 0 0 4px; + margin: 0; + margin-left: 10px; +} + +#changelist .actions select:focus { + border-color: var(--body-quiet-color); +} + +#changelist .actions label { + display: inline-block; + vertical-align: middle; + font-size: 0.8125rem; +} + +#changelist .actions .button { + font-size: 0.8125rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--body-bg); + box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; + cursor: pointer; + height: 1.5rem; + line-height: 1; + padding: 4px 8px; + margin: 0; + color: var(--body-fg); +} + +#changelist .actions .button:focus, #changelist .actions .button:hover { + border-color: var(--body-quiet-color); +} diff --git a/static/css/dark_mode.css b/static/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/static/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..242b81a --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,29 @@ +/* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} + +.dashboard .module table th { + width: 100%; +} + +.dashboard .module table td { + white-space: nowrap; +} + +.dashboard .module table td a { + display: block; + padding-right: .6em; +} + +/* RECENT ACTIONS MODULE */ + +.module ul.actionlist { + margin-left: 0; +} + +ul.actionlist li { + list-style-type: none; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/static/css/forms.css b/static/css/forms.css new file mode 100644 index 0000000..9a8dad0 --- /dev/null +++ b/static/css/forms.css @@ -0,0 +1,534 @@ +@import url('widgets.css'); + +/* FORM ROWS */ + +.form-row { + overflow: hidden; + padding: 10px; + font-size: 0.8125rem; + border-bottom: 1px solid var(--hairline-color); +} + +.form-row img, .form-row input { + vertical-align: middle; +} + +.form-row label input[type="checkbox"] { + margin-top: 0; + vertical-align: 0; +} + +form .form-row p { + padding-left: 0; +} + +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + +/* FORM LABELS */ + +label { + font-weight: normal; + color: var(--body-quiet-color); + font-size: 0.8125rem; +} + +.required label, label.required { + font-weight: bold; + color: var(--body-fg); +} + +/* RADIO BUTTONS */ + +form div.radiolist div { + padding-right: 7px; +} + +form div.radiolist.inline div { + display: inline-block; +} + +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { + margin: -2px 4px 0 0; + padding: 0; +} + +form ul.inline { + margin-left: 0; + padding: 0; +} + +form ul.inline li { + float: left; + padding-right: 7px; +} + +/* ALIGNED FIELDSETS */ + +.aligned label { + display: block; + padding: 4px 10px 0 0; + min-width: 160px; + width: 160px; + word-wrap: break-word; + line-height: 1; +} + +.aligned label:not(.vCheckboxLabel):after { + content: ''; + display: inline-block; + vertical-align: middle; + height: 1.625rem; +} + +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { + padding: 6px 0; + margin-top: 0; + margin-bottom: 0; + margin-left: 0; + overflow-wrap: break-word; +} + +.aligned ul label { + display: inline; + float: none; + width: auto; +} + +.aligned .form-row input { + margin-bottom: 0; +} + +.colMS .aligned .vLargeTextField, .colMS .aligned .vXMLLargeTextField { + width: 350px; +} + +form .aligned ul { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned div.radiolist { + display: inline-block; + margin: 0; + padding: 0; +} + +form .aligned p.help, +form .aligned div.help { + margin-top: 0; + margin-left: 160px; + padding-left: 10px; +} + +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-left: 0; + padding-left: 0; + font-weight: normal; +} + +form .aligned p.help:last-child, +form .aligned div.help:last-child { + margin-bottom: 0; + padding-bottom: 0; +} + +form .aligned input + p.help, +form .aligned textarea + p.help, +form .aligned select + p.help, +form .aligned input + div.help, +form .aligned textarea + div.help, +form .aligned select + div.help { + margin-left: 160px; + padding-left: 10px; +} + +form .aligned ul li { + list-style: none; +} + +form .aligned table p { + margin-left: 0; + padding-left: 0; +} + +.aligned .vCheckboxLabel { + float: none; + width: auto; + display: inline-block; + vertical-align: -3px; + padding: 0 0 5px 5px; +} + +.aligned .vCheckboxLabel + p.help, +.aligned .vCheckboxLabel + div.help { + margin-top: -4px; +} + +.colM .aligned .vLargeTextField, .colM .aligned .vXMLLargeTextField { + width: 610px; +} + +fieldset .fieldBox { + margin-right: 20px; +} + +/* WIDE FIELDSETS */ + +.wide label { + width: 200px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-left: 200px; +} + +form .wide p.help, +form .wide div.help { + padding-left: 50px; +} + +form div.help ul { + padding-left: 0; + margin-left: 0; +} + +.colM fieldset.wide .vLargeTextField, .colM fieldset.wide .vXMLLargeTextField { + width: 450px; +} + +/* COLLAPSED FIELDSETS */ + +fieldset.collapsed * { + display: none; +} + +fieldset.collapsed h2, fieldset.collapsed { + display: block; +} + +fieldset.collapsed { + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; +} + +fieldset.collapsed h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +fieldset .collapse-toggle { + color: var(--header-link-color); +} + +fieldset.collapsed .collapse-toggle { + background: transparent; + display: inline; + color: var(--link-fg); +} + +/* MONOSPACE TEXTAREAS */ + +fieldset.monospace textarea { + font-family: var(--font-family-monospace); +} + +/* SUBMIT ROW */ + +.submit-row { + padding: 12px 14px 12px; + margin: 0 0 20px; + background: var(--darkened-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +body.popup .submit-row { + overflow: auto; +} + +.submit-row input { + height: 2.1875rem; + line-height: 0.9375rem; +} + +.submit-row input, .submit-row a { + margin: 0; +} + +.submit-row input.default { + text-transform: uppercase; +} + +.submit-row a.deletelink { + margin-left: auto; +} + +.submit-row a.deletelink { + display: block; + background: var(--delete-button-bg); + border-radius: 4px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.closelink { + display: inline-block; + background: var(--close-button-bg); + border-radius: 4px; + padding: 10px 15px; + height: 0.9375rem; + line-height: 0.9375rem; + color: var(--button-fg); +} + +.submit-row a.deletelink:focus, +.submit-row a.deletelink:hover, +.submit-row a.deletelink:active { + background: var(--delete-button-hover-bg); + text-decoration: none; +} + +.submit-row a.closelink:focus, +.submit-row a.closelink:hover, +.submit-row a.closelink:active { + background: var(--close-button-hover-bg); + text-decoration: none; +} + +/* CUSTOM FORM FIELDS */ + +.vSelectMultipleField { + vertical-align: top; +} + +.vCheckboxField { + border: none; +} + +.vDateField, .vTimeField { + margin-right: 2px; + margin-bottom: 4px; +} + +.vDateField { + min-width: 6.85em; +} + +.vTimeField { + min-width: 4.7em; +} + +.vURLField { + width: 30em; +} + +.vLargeTextField, .vXMLLargeTextField { + width: 48em; +} + +.flatpages-flatpage #id_content { + height: 40.2em; +} + +.module table .vPositiveSmallIntegerField { + width: 2.2em; +} + +.vIntegerField { + width: 5em; +} + +.vBigIntegerField { + width: 10em; +} + +.vForeignKeyRawIdAdminField { + width: 5em; +} + +.vTextField, .vUUIDField { + width: 20em; +} + +/* INLINES */ + +.inline-group { + padding: 0; + margin: 0 0 30px; +} + +.inline-group thead th { + padding: 8px 10px; +} + +.inline-group .aligned label { + width: 160px; +} + +.inline-related { + position: relative; +} + +.inline-related h3 { + margin: 0; + color: var(--body-quiet-color); + padding: 5px; + font-size: 0.8125rem; + background: var(--darkened-bg); + border-top: 1px solid var(--hairline-color); + border-bottom: 1px solid var(--hairline-color); +} + +.inline-related h3 span.delete { + float: right; +} + +.inline-related h3 span.delete label { + margin-left: 2px; + font-size: 0.6875rem; +} + +.inline-related fieldset { + margin: 0; + background: var(--body-bg); + border: none; + width: 100%; +} + +.inline-related fieldset.module h3 { + margin: 0; + padding: 2px 5px 3px 5px; + font-size: 0.6875rem; + text-align: left; + font-weight: bold; + background: #bcd; + color: var(--body-bg); +} + +.inline-group .tabular fieldset.module { + border: none; +} + +.inline-related.tabular fieldset.module table { + width: 100%; + overflow-x: scroll; +} + +.last-related fieldset { + border: none; +} + +.inline-group .tabular tr.has_original td { + padding-top: 2em; +} + +.inline-group .tabular tr td.original { + padding: 2px 0 0 0; + width: 0; + _position: relative; +} + +.inline-group .tabular th.original { + width: 0px; + padding: 0; +} + +.inline-group .tabular td.original p { + position: absolute; + left: 0; + height: 1.1em; + padding: 2px 9px; + overflow: hidden; + font-size: 0.5625rem; + font-weight: bold; + color: var(--body-quiet-color); + _width: 700px; +} + +.inline-group ul.tools { + padding: 0; + margin: 0; + list-style: none; +} + +.inline-group ul.tools li { + display: inline; + padding: 0 5px; +} + +.inline-group div.add-row, +.inline-group .tabular tr.add-row td { + color: var(--body-quiet-color); + background: var(--darkened-bg); + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group .tabular tr.add-row td { + padding: 8px 10px; + border-bottom: 1px solid var(--hairline-color); +} + +.inline-group ul.tools a.add, +.inline-group div.add-row a, +.inline-group .tabular tr.add-row td a { + background: url(../img/icon-addlink.svg) 0 1px no-repeat; + padding-left: 16px; + font-size: 0.75rem; +} + +.empty-form { + display: none; +} + +/* RELATED FIELD ADD ONE / LOOKUP */ + +.related-lookup { + margin-left: 5px; + display: inline-block; + vertical-align: middle; + background-repeat: no-repeat; + background-size: 14px; +} + +.related-lookup { + width: 1rem; + height: 1rem; + background-image: url(../img/search.svg); +} + +form .related-widget-wrapper ul { + display: inline-block; + margin-left: 0; + padding-left: 0; +} + +.clearable-file-input input { + margin-top: 0; +} diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..389772f --- /dev/null +++ b/static/css/login.css @@ -0,0 +1,61 @@ +/* LOGIN FORM */ + +.login { + background: var(--darkened-bg); + height: auto; +} + +.login #header { + height: auto; + padding: 15px 16px; + justify-content: center; +} + +.login #header h1 { + font-size: 1.125rem; + margin: 0; +} + +.login #header h1 a { + color: var(--header-link-color); +} + +.login #content { + padding: 20px 20px 0; +} + +.login #container { + background: var(--body-bg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + overflow: hidden; + width: 28em; + min-width: 300px; + margin: 100px auto; + height: auto; +} + +.login .form-row { + padding: 4px 0; +} + +.login .form-row label { + display: block; + line-height: 2em; +} + +.login .form-row #id_username, .login .form-row #id_password { + padding: 8px; + width: 100%; + box-sizing: border-box; +} + +.login .submit-row { + padding: 1em 0 0 0; + margin: 0; + text-align: center; +} + +.login .password-reset-link { + text-align: center; +} diff --git a/static/css/nav_sidebar.css b/static/css/nav_sidebar.css new file mode 100644 index 0000000..f76e6ce --- /dev/null +++ b/static/css/nav_sidebar.css @@ -0,0 +1,144 @@ +.sticky { + position: sticky; + top: 0; + max-height: 100vh; +} + +.toggle-nav-sidebar { + z-index: 20; + left: 0; + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 23px; + width: 23px; + border: 0; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + cursor: pointer; + font-size: 1.25rem; + color: var(--link-fg); + padding: 0; +} + +[dir="rtl"] .toggle-nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; +} + +.toggle-nav-sidebar:hover, +.toggle-nav-sidebar:focus { + background-color: var(--darkened-bg); +} + +#nav-sidebar { + z-index: 15; + flex: 0 0 275px; + left: -276px; + margin-left: -276px; + border-top: 1px solid transparent; + border-right: 1px solid var(--hairline-color); + background-color: var(--body-bg); + overflow: auto; +} + +[dir="rtl"] #nav-sidebar { + border-left: 1px solid var(--hairline-color); + border-right: 0; + left: 0; + margin-left: 0; + right: -276px; + margin-right: -276px; +} + +.toggle-nav-sidebar::before { + content: '\00BB'; +} + +.main.shifted .toggle-nav-sidebar::before { + content: '\00AB'; +} + +.main > #nav-sidebar { + visibility: hidden; +} + +.main.shifted > #nav-sidebar { + margin-left: 0; + visibility: visible; +} + +[dir="rtl"] .main.shifted > #nav-sidebar { + margin-right: 0; +} + +#nav-sidebar .module th { + width: 100%; + overflow-wrap: anywhere; +} + +#nav-sidebar .module th, +#nav-sidebar .module caption { + padding-left: 16px; +} + +#nav-sidebar .module td { + white-space: nowrap; +} + +[dir="rtl"] #nav-sidebar .module th, +[dir="rtl"] #nav-sidebar .module caption { + padding-left: 8px; + padding-right: 16px; +} + +#nav-sidebar .current-app .section:link, +#nav-sidebar .current-app .section:visited { + color: var(--header-color); + font-weight: bold; +} + +#nav-sidebar .current-model { + background: var(--selected-row); +} + +.main > #nav-sidebar + .content { + max-width: calc(100% - 23px); +} + +.main.shifted > #nav-sidebar + .content { + max-width: calc(100% - 299px); +} + +@media (max-width: 767px) { + #nav-sidebar, #toggle-nav-sidebar { + display: none; + } + + .main > #nav-sidebar + .content, + .main.shifted > #nav-sidebar + .content { + max-width: 100%; + } +} + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/static/css/profile.css b/static/css/profile.css new file mode 100644 index 0000000..2751d20 --- /dev/null +++ b/static/css/profile.css @@ -0,0 +1,86 @@ +.profile-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 2rem; + margin: 2rem 0; +} + +.profile-header { + background-color: #1a1b2e; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.level-badge { + background: linear-gradient(135deg, #7e3ace, #6425c9); + color: #ffffff; + border-radius: 50%; + padding: 0.5rem; +} + +.profile-info .username { + color: #8a2be2; + font-size: 1.8rem; +} + +.profile-bio { + background-color: #1a1b2e; + border-radius: 8px; + padding: 1.5rem; +} + +.stats-card, .achievements-card { + background-color: #1a1b2e; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.stat-item { + background-color: #242642; + border-radius: 6px; + padding: 1rem; + text-align: center; +} + +.stat-value { + color: #8a2be2; + font-size: 1.4rem; + font-weight: bold; +} + +.badge-item { + background-color: #242642; + border-radius: 6px; + padding: 0.8rem; + transition: transform 0.2s; +} + +.badge-item:hover { + transform: translateY(-2px); + background-color: #2f325a; +} + +.profile-actions .btn-primary { + background-color: #8a2be2; + border: none; + color: white; + transition: background-color 0.2s; +} + +.profile-actions .btn-primary:hover { + background-color: #7e3ace; +} + +.profile-actions .btn-secondary { + background-color: #242642; + border: 1px solid #8a2be2; + color: #8a2be2; + transition: all 0.2s; +} + +.profile-actions .btn-secondary:hover { + background-color: #8a2be2; + color: white; +} diff --git a/static/css/responsive.css b/static/css/responsive.css new file mode 100644 index 0000000..1d0a188 --- /dev/null +++ b/static/css/responsive.css @@ -0,0 +1,999 @@ +/* Tablets */ + +input[type="submit"], button { + -webkit-appearance: none; + appearance: none; +} + +@media (max-width: 1024px) { + /* Basic */ + + html { + -webkit-text-size-adjust: 100%; + } + + td, th { + padding: 10px; + font-size: 0.875rem; + } + + .small { + font-size: 0.75rem; + } + + /* Layout */ + + #container { + min-width: 0; + } + + #content { + padding: 15px 20px 20px; + } + + div.breadcrumbs { + padding: 10px 30px; + } + + /* Header */ + + #header { + flex-direction: column; + padding: 15px 30px; + justify-content: flex-start; + } + + #branding h1 { + margin: 0 0 8px; + line-height: 1.2; + } + + #user-tools { + margin: 0; + font-weight: 400; + line-height: 1.85; + text-align: left; + } + + #user-tools a { + display: inline-block; + line-height: 1.4; + } + + /* Dashboard */ + + .dashboard #content { + width: auto; + } + + #content-related { + margin-right: -290px; + } + + .colSM #content-related { + margin-left: -290px; + } + + .colMS { + margin-right: 290px; + } + + .colSM { + margin-left: 290px; + } + + .dashboard .module table td a { + padding-right: 0; + } + + td .changelink, td .addlink { + font-size: 0.8125rem; + } + + /* Changelist */ + + #toolbar { + border: none; + padding: 15px; + } + + #changelist-search > div { + display: flex; + flex-wrap: nowrap; + max-width: 480px; + } + + #changelist-search label { + line-height: 1.375rem; + } + + #toolbar form #searchbar { + flex: 1 0 auto; + width: 0; + height: 1.375rem; + margin: 0 10px 0 6px; + } + + #toolbar form input[type=submit] { + flex: 0 1 auto; + } + + #changelist-search .quiet { + width: 0; + flex: 1 0 auto; + margin: 5px 0 0 25px; + } + + #changelist .actions { + display: flex; + flex-wrap: wrap; + padding: 15px 0; + } + + #changelist .actions label { + display: flex; + } + + #changelist .actions select { + background: var(--body-bg); + } + + #changelist .actions .button { + min-width: 48px; + margin: 0 10px; + } + + #changelist .actions span.all, + #changelist .actions span.clear, + #changelist .actions span.question, + #changelist .actions span.action-counter { + font-size: 0.6875rem; + margin: 0 10px 0 0; + } + + #changelist-filter { + flex-basis: 200px; + } + + .change-list .filtered .results, + .change-list .filtered .paginator, + .filtered #toolbar, + .filtered .actions, + + #changelist .paginator { + border-top-color: var(--hairline-color); /* XXX Is this used at all? */ + } + + #changelist .results + .paginator { + border-top: none; + } + + /* Forms */ + + label { + font-size: 0.875rem; + } + + .form-row input[type=text], + .form-row input[type=password], + .form-row input[type=email], + .form-row input[type=url], + .form-row input[type=tel], + .form-row input[type=number], + .form-row textarea, + .form-row select, + .form-row .vTextField { + box-sizing: border-box; + margin: 0; + padding: 6px 8px; + min-height: 2.25rem; + font-size: 0.875rem; + } + + .form-row select { + height: 2.25rem; + } + + .form-row select[multiple] { + height: auto; + min-height: 0; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--hairline-color); + } + + textarea { + max-width: 100%; + max-height: 120px; + } + + .aligned label { + padding-top: 6px; + } + + .aligned .related-lookup, + .aligned .datetimeshortcuts, + .aligned .related-lookup + strong { + align-self: center; + margin-left: 15px; + } + + form .aligned div.radiolist { + margin-left: 2px; + } + + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + + /* Related widget */ + + .related-widget-wrapper { + float: none; + } + + .related-widget-wrapper-link + .selector { + max-width: calc(100% - 30px); + margin-right: 15px; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 10px; + } + + /* Selector */ + + .selector { + display: flex; + width: 100%; + } + + .selector .selector-filter { + display: flex; + align-items: center; + } + + .selector .selector-filter label { + margin: 0 8px 0 0; + } + + .selector .selector-filter input { + width: auto; + min-height: 0; + flex: 1 1; + } + + .selector-available, .selector-chosen { + width: auto; + flex: 1 1; + display: flex; + flex-direction: column; + } + + .selector select { + width: 100%; + flex: 1 0 auto; + margin-bottom: 5px; + } + + .selector ul.selector-chooser { + width: 26px; + height: 52px; + padding: 2px 0; + margin: auto 15px; + border-radius: 20px; + transform: translateY(-10px); + } + + .selector-add, .selector-remove { + width: 20px; + height: 20px; + background-size: 20px auto; + } + + .selector-add { + background-position: 0 -120px; + } + + .selector-remove { + background-position: 0 -80px; + } + + a.selector-chooseall, a.selector-clearall { + align-self: center; + } + + .stacked { + flex-direction: column; + max-width: 480px; + } + + .stacked > * { + flex: 0 1 auto; + } + + .stacked select { + margin-bottom: 0; + } + + .stacked .selector-available, .stacked .selector-chosen { + width: auto; + } + + .stacked ul.selector-chooser { + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto; + transform: none; + } + + .stacked .selector-chooser li { + padding: 3px; + } + + .stacked .selector-add, .stacked .selector-remove { + background-size: 20px auto; + } + + .stacked .selector-add { + background-position: 0 -40px; + } + + .stacked .active.selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -140px; + } + + .stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -60px; + } + + .stacked .selector-remove { + background-position: 0 0; + } + + .stacked .active.selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -100px; + } + + .stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -20px; + } + + .help-tooltip, .selector .help-icon { + display: none; + } + + .datetime input { + width: 50%; + max-width: 120px; + } + + .datetime span { + font-size: 0.8125rem; + } + + .datetime .timezonewarning { + display: block; + font-size: 0.6875rem; + color: var(--body-quiet-color); + } + + .datetimeshortcuts { + color: var(--border-color); /* XXX Redundant, .datetime span also sets #ccc */ + } + + .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + width: 75%; + } + + .inline-group { + overflow: auto; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 55px; + background-position: 30px 12px; + } + + ul.messagelist li.error { + background-position: 30px 12px; + } + + ul.messagelist li.warning { + background-position: 30px 14px; + } + + /* Login */ + + .login #header { + padding: 15px 20px; + } + + .login #branding h1 { + margin: 0; + } + + /* GIS */ + + div.olMap { + max-width: calc(100vw - 30px); + max-height: 300px; + } + + .olMap + .clear_features { + display: block; + margin-top: 10px; + } + + /* Docs */ + + .module table.xfull { + width: 100%; + } + + pre.literal-block { + overflow: auto; + } +} + +/* Mobile */ + +@media (max-width: 767px) { + /* Layout */ + + #header, #content, #footer { + padding: 15px; + } + + #footer:empty { + padding: 0; + } + + div.breadcrumbs { + padding: 10px 15px; + } + + /* Dashboard */ + + .colMS, .colSM { + margin: 0; + } + + #content-related, .colSM #content-related { + width: 100%; + margin: 0; + } + + #content-related .module { + margin-bottom: 0; + } + + #content-related .module h2 { + padding: 10px 15px; + font-size: 1rem; + } + + /* Changelist */ + + #changelist { + align-items: stretch; + flex-direction: column; + } + + #toolbar { + padding: 10px; + } + + #changelist-filter { + margin-left: 0; + } + + #changelist .actions label { + flex: 1 1; + } + + #changelist .actions select { + flex: 1 0; + width: 100%; + } + + #changelist .actions span { + flex: 1 0 100%; + } + + #changelist-filter { + position: static; + width: auto; + margin-top: 30px; + } + + .object-tools { + float: none; + margin: 0 0 15px; + padding: 0; + overflow: hidden; + } + + .object-tools li { + height: auto; + margin-left: 0; + } + + .object-tools li + li { + margin-left: 15px; + } + + /* Forms */ + + .form-row { + padding: 15px 0; + } + + .aligned .form-row, + .aligned .form-row > div { + max-width: 100vw; + } + + .aligned .form-row > div { + width: calc(100vw - 30px); + } + + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + + textarea { + max-width: none; + } + + .vURLField { + width: auto; + } + + fieldset .fieldBox + .fieldBox { + margin-top: 15px; + padding-top: 15px; + } + + fieldset.collapsed .form-row { + display: none; + } + + .aligned label { + width: 100%; + min-width: auto; + padding: 0 0 10px; + } + + .aligned label:after { + max-height: 0; + } + + .aligned .form-row input, + .aligned .form-row select, + .aligned .form-row textarea { + flex: 1 1 auto; + max-width: 100%; + } + + .aligned .checkbox-row input { + flex: 0 1 auto; + margin: 0; + } + + .aligned .vCheckboxLabel { + flex: 1 0; + padding: 1px 0 0 5px; + } + + .aligned label + p, + .aligned label + div.help, + .aligned label + div.readonly { + padding: 0; + margin-left: 0; + } + + .aligned p.file-upload { + font-size: 0.8125rem; + } + + span.clearable-file-input { + margin-left: 15px; + } + + span.clearable-file-input label { + font-size: 0.8125rem; + padding-bottom: 0; + } + + .aligned .timezonewarning { + flex: 1 0 100%; + margin-top: 5px; + } + + form .aligned .form-row div.help { + width: 100%; + margin: 5px 0 0; + padding: 0; + } + + form .aligned ul, + form .aligned ul.errorlist { + margin-left: 0; + padding-left: 0; + } + + form .aligned div.radiolist { + margin-top: 5px; + margin-right: 15px; + margin-bottom: -3px; + } + + form .aligned div.radiolist:not(.inline) div + div { + margin-top: 5px; + } + + /* Related widget */ + + .related-widget-wrapper { + width: 100%; + display: flex; + align-items: flex-start; + } + + .related-widget-wrapper .selector { + order: 1; + } + + .related-widget-wrapper > a { + order: 2; + } + + .related-widget-wrapper .radiolist ~ a { + align-self: flex-end; + } + + .related-widget-wrapper > select ~ a { + align-self: center; + } + + select + .related-widget-wrapper-link, + .related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 15px; + } + + /* Selector */ + + .selector { + flex-direction: column; + } + + .selector > * { + float: none; + } + + .selector-available, .selector-chosen { + margin-bottom: 0; + flex: 1 1 auto; + } + + .selector select { + max-height: 96px; + } + + .selector ul.selector-chooser { + display: block; + float: none; + width: 52px; + height: 26px; + padding: 0 2px; + margin: 15px auto 20px; + transform: none; + } + + .selector ul.selector-chooser li { + float: left; + } + + .selector-remove { + background-position: 0 0; + } + + .active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -20px; + } + + .selector-add { + background-position: 0 -40px; + } + + .active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -60px; + } + + /* Inlines */ + + .inline-group[data-inline-type="stacked"] .inline-related { + border: 1px solid var(--hairline-color); + border-radius: 4px; + margin-top: 15px; + overflow: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related > * { + box-sizing: border-box; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module { + padding: 0 10px; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row { + border-top: 1px solid var(--hairline-color); + border-bottom: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related .module .form-row:first-child { + border-top: none; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 { + padding: 10px; + border-top-width: 0; + border-bottom-width: 2px; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 .inline_label { + margin-right: auto; + } + + .inline-group[data-inline-type="stacked"] .inline-related h3 span.delete { + float: none; + flex: 1 1 100%; + margin-top: 5px; + } + + .inline-group[data-inline-type="stacked"] .aligned .form-row > div:not([class]) { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] .aligned label { + width: 100%; + } + + .inline-group[data-inline-type="stacked"] div.add-row { + margin-top: 15px; + border: 1px solid var(--hairline-color); + border-radius: 4px; + } + + .inline-group div.add-row, + .inline-group .tabular tr.add-row td { + padding: 0; + } + + .inline-group div.add-row a, + .inline-group .tabular tr.add-row td a { + display: block; + padding: 8px 10px 8px 26px; + background-position: 8px 9px; + } + + /* Submit row */ + + .submit-row { + padding: 10px; + margin: 0 0 15px; + flex-direction: column; + gap: 8px; + } + + .submit-row input, .submit-row input.default, .submit-row a { + text-align: center; + } + + .submit-row a.closelink { + padding: 10px 0; + text-align: center; + } + + .submit-row a.deletelink { + margin: 0; + } + + /* Messages */ + + ul.messagelist li { + padding-left: 40px; + background-position: 15px 12px; + } + + ul.messagelist li.error { + background-position: 15px 12px; + } + + ul.messagelist li.warning { + background-position: 15px 14px; + } + + /* Paginator */ + + .paginator .this-page, .paginator a:link, .paginator a:visited { + padding: 4px 10px; + } + + /* Login */ + + body.login { + padding: 0 15px; + } + + .login #container { + width: auto; + max-width: 480px; + margin: 50px auto; + } + + .login #header, + .login #content { + padding: 15px; + } + + .login #content-main { + float: none; + } + + .login .form-row { + padding: 0; + } + + .login .form-row + .form-row { + margin-top: 15px; + } + + .login .form-row label { + margin: 0 0 5px; + line-height: 1.2; + } + + .login .submit-row { + padding: 15px 0 0; + } + + .login br { + display: none; + } + + .login .submit-row input { + margin: 0; + text-transform: uppercase; + } + + .errornote { + margin: 0 0 20px; + padding: 8px 12px; + font-size: 0.8125rem; + } + + /* Calendar and clock */ + + .calendarbox, .clockbox { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%); + margin: 0; + border: none; + overflow: visible; + } + + .calendarbox:before, .clockbox:before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.75); + transform: translate(-50%, -50%); + } + + .calendarbox > *, .clockbox > * { + position: relative; + z-index: 1; + } + + .calendarbox > div:first-child { + z-index: 2; + } + + .calendarbox .calendar, .clockbox h2 { + border-radius: 4px 4px 0 0; + overflow: hidden; + } + + .calendarbox .calendar-cancel, .clockbox .calendar-cancel { + border-radius: 0 0 4px 4px; + overflow: hidden; + } + + .calendar-shortcuts { + padding: 10px 0; + font-size: 0.75rem; + line-height: 0.75rem; + } + + .calendar-shortcuts a { + margin: 0 4px; + } + + .timelist a { + background: var(--body-bg); + padding: 4px; + } + + .calendar-cancel { + padding: 8px 10px; + } + + .clockbox h2 { + padding: 8px 15px; + } + + .calendar caption { + padding: 10px; + } + + .calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + z-index: 1; + top: 10px; + } + + /* History */ + + table#change-history tbody th, table#change-history tbody td { + font-size: 0.8125rem; + word-break: break-word; + } + + table#change-history tbody th { + width: auto; + } + + /* Docs */ + + table.model tbody th, table.model tbody td { + font-size: 0.8125rem; + word-break: break-word; + } +} diff --git a/static/css/responsive_rtl.css b/static/css/responsive_rtl.css new file mode 100644 index 0000000..31dc8ff --- /dev/null +++ b/static/css/responsive_rtl.css @@ -0,0 +1,84 @@ +/* TABLETS */ + +@media (max-width: 1024px) { + [dir="rtl"] .colMS { + margin-right: 0; + } + + [dir="rtl"] #user-tools { + text-align: right; + } + + [dir="rtl"] #changelist .actions label { + padding-left: 10px; + padding-right: 0; + } + + [dir="rtl"] #changelist .actions select { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .change-list .filtered .results, + [dir="rtl"] .change-list .filtered .paginator, + [dir="rtl"] .filtered #toolbar, + [dir="rtl"] .filtered div.xfull, + [dir="rtl"] .filtered .actions, + [dir="rtl"] #changelist-filter { + margin-left: 0; + } + + [dir="rtl"] .inline-group ul.tools a.add, + [dir="rtl"] .inline-group div.add-row a, + [dir="rtl"] .inline-group .tabular tr.add-row td a { + padding: 8px 26px 8px 10px; + background-position: calc(100% - 8px) 9px; + } + + [dir="rtl"] .related-widget-wrapper-link + .selector { + margin-right: 0; + margin-left: 15px; + } + + [dir="rtl"] .selector .selector-filter label { + margin-right: 0; + margin-left: 8px; + } + + [dir="rtl"] .object-tools li { + float: right; + } + + [dir="rtl"] .object-tools li + li { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .dashboard .module table td a { + padding-left: 0; + padding-right: 16px; + } +} + +/* MOBILE */ + +@media (max-width: 767px) { + [dir="rtl"] .aligned .related-lookup, + [dir="rtl"] .aligned .datetimeshortcuts { + margin-left: 0; + margin-right: 15px; + } + + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { + margin-right: 0; + } + + [dir="rtl"] #changelist-filter { + margin-left: 0; + margin-right: 0; + } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } +} diff --git a/static/css/rtl.css b/static/css/rtl.css new file mode 100644 index 0000000..c349a93 --- /dev/null +++ b/static/css/rtl.css @@ -0,0 +1,298 @@ +/* GLOBAL */ + +th { + text-align: right; +} + +.module h2, .module caption { + text-align: right; +} + +.module ul, .module ol { + margin-left: 0; + margin-right: 1.5em; +} + +.viewlink, .addlink, .changelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.deletelink { + padding-left: 0; + padding-right: 16px; + background-position: 100% 1px; +} + +.object-tools { + float: left; +} + +thead th:first-child, +tfoot td:first-child { + border-left: none; +} + +/* LAYOUT */ + +#user-tools { + right: auto; + left: 0; + text-align: left; +} + +div.breadcrumbs { + text-align: right; +} + +#content-main { + float: right; +} + +#content-related { + float: left; + margin-left: -300px; + margin-right: auto; +} + +.colMS { + margin-left: 300px; + margin-right: 0; +} + +/* SORTABLE TABLES */ + +table thead th.sorted .sortoptions { + float: left; +} + +thead th.sorted .text { + padding-right: 0; + padding-left: 42px; +} + +/* dashboard styles */ + +.dashboard .module table td a { + padding-left: .6em; + padding-right: 16px; +} + +/* changelists styles */ + +.change-list .filtered table { + border-left: none; + border-right: 0px none; +} + +#changelist-filter { + border-left: none; + border-right: none; + margin-left: 0; + margin-right: 30px; +} + +#changelist-filter li.selected { + border-left: none; + padding-left: 10px; + margin-left: 0; + border-right: 5px solid var(--hairline-color); + padding-right: 10px; + margin-right: -15px; +} + +#changelist table tbody td:first-child, #changelist table tbody th:first-child { + border-right: none; + border-left: none; +} + +.paginator .end { + margin-left: 6px; + margin-right: 0; +} + +.paginator input { + margin-left: 0; + margin-right: auto; +} + +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; +} + +.submit-row a.deletelink { + margin-left: 0; + margin-right: auto; +} + +.vDateField, .vTimeField { + margin-left: 2px; +} + +.aligned .form-row input { + margin-left: 5px; +} + +form .aligned ul { + margin-right: 163px; + padding-right: 10px; + margin-left: 0; + padding-left: 0; +} + +form ul.inline li { + float: right; + padding-right: 0; + padding-left: 7px; +} + +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; +} + +fieldset .fieldBox { + margin-left: 20px; + margin-right: 0; +} + +.errorlist li { + background-position: 100% 12px; + padding: 0; +} + +.errornote { + background-position: 100% 12px; + padding: 10px 12px; +} + +/* WIDGETS */ + +.calendarnav-previous { + top: 0; + left: auto; + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; +} + +.calendarnav-next { + top: 0; + right: auto; + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; +} + +.calendar caption, .calendarbox h2 { + text-align: center; +} + +.selector { + float: right; +} + +.selector .selector-filter { + text-align: right; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + +.inline-deletelink { + float: left; +} + +form .form-row p.datetime { + overflow: hidden; +} + +.related-widget-wrapper { + float: right; +} + +/* MISC */ + +.inline-related h2, .inline-group h2 { + text-align: right +} + +.inline-related h3 span.delete { + padding-right: 20px; + padding-left: inherit; + left: 10px; + right: inherit; + float:left; +} + +.inline-related h3 span.delete label { + margin-left: inherit; + margin-right: 2px; +} diff --git a/static/css/vendor/select2/LICENSE-SELECT2.md b/static/css/vendor/select2/LICENSE-SELECT2.md new file mode 100644 index 0000000..8cb8a2b --- /dev/null +++ b/static/css/vendor/select2/LICENSE-SELECT2.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2012-2017 Kevin Brown, Igor Vaynberg, and Select2 contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/static/css/vendor/select2/select2.css b/static/css/vendor/select2/select2.css new file mode 100644 index 0000000..750b320 --- /dev/null +++ b/static/css/vendor/select2/select2.css @@ -0,0 +1,481 @@ +.select2-container { + box-sizing: border-box; + display: inline-block; + margin: 0; + position: relative; + vertical-align: middle; } + .select2-container .select2-selection--single { + box-sizing: border-box; + cursor: pointer; + display: block; + height: 28px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--single .select2-selection__rendered { + display: block; + padding-left: 8px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-selection--single .select2-selection__clear { + position: relative; } + .select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered { + padding-right: 8px; + padding-left: 20px; } + .select2-container .select2-selection--multiple { + box-sizing: border-box; + cursor: pointer; + display: block; + min-height: 32px; + user-select: none; + -webkit-user-select: none; } + .select2-container .select2-selection--multiple .select2-selection__rendered { + display: inline-block; + overflow: hidden; + padding-left: 8px; + text-overflow: ellipsis; + white-space: nowrap; } + .select2-container .select2-search--inline { + float: left; } + .select2-container .select2-search--inline .select2-search__field { + box-sizing: border-box; + border: none; + font-size: 100%; + margin-top: 5px; + padding: 0; } + .select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + +.select2-dropdown { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + box-sizing: border-box; + display: block; + position: absolute; + left: -100000px; + width: 100%; + z-index: 1051; } + +.select2-results { + display: block; } + +.select2-results__options { + list-style: none; + margin: 0; + padding: 0; } + +.select2-results__option { + padding: 6px; + user-select: none; + -webkit-user-select: none; } + .select2-results__option[aria-selected] { + cursor: pointer; } + +.select2-container--open .select2-dropdown { + left: 0; } + +.select2-container--open .select2-dropdown--above { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--open .select2-dropdown--below { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-search--dropdown { + display: block; + padding: 4px; } + .select2-search--dropdown .select2-search__field { + padding: 4px; + width: 100%; + box-sizing: border-box; } + .select2-search--dropdown .select2-search__field::-webkit-search-cancel-button { + -webkit-appearance: none; } + .select2-search--dropdown.select2-search--hide { + display: none; } + +.select2-close-mask { + border: 0; + margin: 0; + padding: 0; + display: block; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 99; + background-color: #fff; + filter: alpha(opacity=0); } + +.select2-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; } + +.select2-container--default .select2-selection--single { + background-color: #fff; + border: 1px solid #aaa; + border-radius: 4px; } + .select2-container--default .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--default .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; } + .select2-container--default .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--default .select2-selection--single .select2-selection__arrow { + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; } + .select2-container--default .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow { + left: 1px; + right: auto; } + +.select2-container--default.select2-container--disabled .select2-selection--single { + background-color: #eee; + cursor: default; } + .select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear { + display: none; } + +.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--default .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered { + box-sizing: border-box; + list-style: none; + margin: 0; + padding: 0 5px; + width: 100%; } + .select2-container--default .select2-selection--multiple .select2-selection__rendered li { + list-style: none; } + .select2-container--default .select2-selection--multiple .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-top: 5px; + margin-right: 10px; + padding: 1px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove { + color: #999; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #333; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice, .select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline { + float: right; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + margin-left: 5px; + margin-right: auto; } + +.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--default.select2-container--focus .select2-selection--multiple { + border: solid black 1px; + outline: 0; } + +.select2-container--default.select2-container--disabled .select2-selection--multiple { + background-color: #eee; + cursor: default; } + +.select2-container--default.select2-container--disabled .select2-selection__choice__remove { + display: none; } + +.select2-container--default.select2-container--open.select2-container--above .select2-selection--single, .select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple { + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--default.select2-container--open.select2-container--below .select2-selection--single, .select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--default .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; } + +.select2-container--default .select2-search--inline .select2-search__field { + background: transparent; + border: none; + outline: 0; + box-shadow: none; + -webkit-appearance: textfield; } + +.select2-container--default .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--default .select2-results__option[role=group] { + padding: 0; } + +.select2-container--default .select2-results__option[aria-disabled=true] { + color: #999; } + +.select2-container--default .select2-results__option[aria-selected=true] { + background-color: #ddd; } + +.select2-container--default .select2-results__option .select2-results__option { + padding-left: 1em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__group { + padding-left: 0; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option { + margin-left: -1em; + padding-left: 2em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -2em; + padding-left: 3em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -3em; + padding-left: 4em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -4em; + padding-left: 5em; } + .select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option { + margin-left: -5em; + padding-left: 6em; } + +.select2-container--default .select2-results__option--highlighted[aria-selected] { + background-color: #5897fb; + color: white; } + +.select2-container--default .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic .select2-selection--single { + background-color: #f7f7f7; + border: 1px solid #aaa; + border-radius: 4px; + outline: 0; + background-image: -webkit-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: -o-linear-gradient(top, white 50%, #eeeeee 100%); + background-image: linear-gradient(to bottom, white 50%, #eeeeee 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + .select2-container--classic .select2-selection--single:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--single .select2-selection__rendered { + color: #444; + line-height: 28px; } + .select2-container--classic .select2-selection--single .select2-selection__clear { + cursor: pointer; + float: right; + font-weight: bold; + margin-right: 10px; } + .select2-container--classic .select2-selection--single .select2-selection__placeholder { + color: #999; } + .select2-container--classic .select2-selection--single .select2-selection__arrow { + background-color: #ddd; + border: none; + border-left: 1px solid #aaa; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + height: 26px; + position: absolute; + top: 1px; + right: 1px; + width: 20px; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, #cccccc 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, #cccccc 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0); } + .select2-container--classic .select2-selection--single .select2-selection__arrow b { + border-color: #888 transparent transparent transparent; + border-style: solid; + border-width: 5px 4px 0 4px; + height: 0; + left: 50%; + margin-left: -4px; + margin-top: -2px; + position: absolute; + top: 50%; + width: 0; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear { + float: left; } + +.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow { + border: none; + border-right: 1px solid #aaa; + border-radius: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + left: 1px; + right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--single { + border: 1px solid #5897fb; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow { + background: transparent; + border: none; } + .select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b { + border-color: transparent transparent #888 transparent; + border-width: 0 4px 5px 4px; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-image: -webkit-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: -o-linear-gradient(top, white 0%, #eeeeee 50%); + background-image: linear-gradient(to bottom, white 0%, #eeeeee 50%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0); } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + background-image: -webkit-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: -o-linear-gradient(top, #eeeeee 50%, white 100%); + background-image: linear-gradient(to bottom, #eeeeee 50%, white 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0); } + +.select2-container--classic .select2-selection--multiple { + background-color: white; + border: 1px solid #aaa; + border-radius: 4px; + cursor: text; + outline: 0; } + .select2-container--classic .select2-selection--multiple:focus { + border: 1px solid #5897fb; } + .select2-container--classic .select2-selection--multiple .select2-selection__rendered { + list-style: none; + margin: 0; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__clear { + display: none; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice { + background-color: #e4e4e4; + border: 1px solid #aaa; + border-radius: 4px; + cursor: default; + float: left; + margin-right: 5px; + margin-top: 5px; + padding: 0 5px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove { + color: #888; + cursor: pointer; + display: inline-block; + font-weight: bold; + margin-right: 2px; } + .select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover { + color: #555; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice { + float: right; + margin-left: 5px; + margin-right: auto; } + +.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove { + margin-left: 2px; + margin-right: auto; } + +.select2-container--classic.select2-container--open .select2-selection--multiple { + border: 1px solid #5897fb; } + +.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; } + +.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } + +.select2-container--classic .select2-search--dropdown .select2-search__field { + border: 1px solid #aaa; + outline: 0; } + +.select2-container--classic .select2-search--inline .select2-search__field { + outline: 0; + box-shadow: none; } + +.select2-container--classic .select2-dropdown { + background-color: white; + border: 1px solid transparent; } + +.select2-container--classic .select2-dropdown--above { + border-bottom: none; } + +.select2-container--classic .select2-dropdown--below { + border-top: none; } + +.select2-container--classic .select2-results > .select2-results__options { + max-height: 200px; + overflow-y: auto; } + +.select2-container--classic .select2-results__option[role=group] { + padding: 0; } + +.select2-container--classic .select2-results__option[aria-disabled=true] { + color: grey; } + +.select2-container--classic .select2-results__option--highlighted[aria-selected] { + background-color: #3875d7; + color: white; } + +.select2-container--classic .select2-results__group { + cursor: default; + display: block; + padding: 6px; } + +.select2-container--classic.select2-container--open .select2-dropdown { + border-color: #5897fb; } diff --git a/static/css/vendor/select2/select2.min.css b/static/css/vendor/select2/select2.min.css new file mode 100644 index 0000000..7c18ad5 --- /dev/null +++ b/static/css/vendor/select2/select2.min.css @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/static/css/widgets.css b/static/css/widgets.css new file mode 100644 index 0000000..1104e8b --- /dev/null +++ b/static/css/widgets.css @@ -0,0 +1,604 @@ +/* SELECTOR (FILTER INTERFACE) */ + +.selector { + width: 800px; + float: left; + display: flex; +} + +.selector select { + width: 380px; + height: 17.2em; + flex: 1 0 auto; +} + +.selector-available, .selector-chosen { + width: 380px; + text-align: center; + margin-bottom: 5px; + display: flex; + flex-direction: column; +} + +.selector-available h2, .selector-chosen h2 { + border: 1px solid var(--border-color); + border-radius: 4px 4px 0 0; +} + +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + +.selector-chosen h2 { + background: var(--primary); + color: var(--header-link-color); +} + +.selector .selector-available h2 { + background: var(--darkened-bg); + color: var(--body-quiet-color); +} + +.selector .selector-filter { + border: 1px solid var(--border-color); + border-width: 0 1px; + padding: 8px; + color: var(--body-quiet-color); + font-size: 0.625rem; + margin: 0; + text-align: left; +} + +.selector .selector-filter label, +.inline-group .aligned .selector .selector-filter label { + float: left; + margin: 7px 0 0; + width: 18px; + height: 18px; + padding: 0; + overflow: hidden; + line-height: 1; + min-width: auto; +} + +.selector .selector-available input, +.selector .selector-chosen input { + width: 320px; + margin-left: 8px; +} + +.selector ul.selector-chooser { + align-self: center; + width: 22px; + background-color: var(--selected-bg); + border-radius: 10px; + margin: 0 5px; + padding: 0; + transform: translateY(-17px); +} + +.selector-chooser li { + margin: 0; + padding: 3px; + list-style-type: none; +} + +.selector select { + padding: 0 10px; + margin: 0 0 10px; + border-radius: 0 0 4px 4px; +} +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} + +.selector-add, .selector-remove { + width: 16px; + height: 16px; + display: block; + text-indent: -3000px; + overflow: hidden; + cursor: default; + opacity: 0.55; +} + +.active.selector-add, .active.selector-remove { + opacity: 1; +} + +.active.selector-add:hover, .active.selector-remove:hover { + cursor: pointer; +} + +.selector-add { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -112px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -80px; +} + +a.selector-chooseall, a.selector-clearall { + display: inline-block; + height: 16px; + text-align: left; + margin: 1px auto 3px; + overflow: hidden; + font-weight: bold; + line-height: 16px; + color: var(--body-quiet-color); + text-decoration: none; + opacity: 0.55; +} + +a.active.selector-chooseall:focus, a.active.selector-clearall:focus, +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + color: var(--link-fg); +} + +a.active.selector-chooseall, a.active.selector-clearall { + opacity: 1; +} + +a.active.selector-chooseall:hover, a.active.selector-clearall:hover { + cursor: pointer; +} + +a.selector-chooseall { + padding: 0 18px 0 0; + background: url(../img/selector-icons.svg) right -160px no-repeat; + cursor: default; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -176px; +} + +a.selector-clearall { + padding: 0 0 0 18px; + background: url(../img/selector-icons.svg) 0 -128px no-repeat; + cursor: default; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -144px; +} + +/* STACKED SELECTORS */ + +.stacked { + float: left; + width: 490px; + display: block; +} + +.stacked select { + width: 480px; + height: 10.1em; +} + +.stacked .selector-available, .stacked .selector-chosen { + width: 480px; +} + +.stacked .selector-available { + margin-bottom: 0; +} + +.stacked .selector-available input { + width: 422px; +} + +.stacked ul.selector-chooser { + height: 22px; + width: 50px; + margin: 0 0 10px 40%; + background-color: #eee; + border-radius: 10px; + transform: none; +} + +.stacked .selector-chooser li { + float: left; + padding: 3px 3px 3px 5px; +} + +.stacked .selector-chooseall, .stacked .selector-clearall { + display: none; +} + +.stacked .selector-add { + background: url(../img/selector-icons.svg) 0 -32px no-repeat; + cursor: default; +} + +.stacked .active.selector-add { + background-position: 0 -32px; + cursor: pointer; +} + +.stacked .active.selector-add:focus, .stacked .active.selector-add:hover { + background-position: 0 -48px; + cursor: pointer; +} + +.stacked .selector-remove { + background: url(../img/selector-icons.svg) 0 0 no-repeat; + cursor: default; +} + +.stacked .active.selector-remove { + background-position: 0 0px; + cursor: pointer; +} + +.stacked .active.selector-remove:focus, .stacked .active.selector-remove:hover { + background-position: 0 -16px; + cursor: pointer; +} + +.selector .help-icon { + background: url(../img/icon-unknown.svg) 0 0 no-repeat; + display: inline-block; + vertical-align: middle; + margin: -2px 0 0 2px; + width: 13px; + height: 13px; +} + +.selector .selector-chosen .help-icon { + background: url(../img/icon-unknown-alt.svg) 0 0 no-repeat; +} + +.selector .search-label-icon { + background: url(../img/search.svg) 0 0 no-repeat; + display: inline-block; + height: 1.125rem; + width: 1.125rem; +} + +/* DATE AND TIME */ + +p.datetime { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-weight: bold; +} + +.datetime span { + white-space: nowrap; + font-weight: normal; + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +.datetime input, .form-row .datetime input.vDateField, .form-row .datetime input.vTimeField { + margin-left: 5px; + margin-bottom: 4px; +} + +table p.datetime { + font-size: 0.6875rem; + margin-left: 0; + padding-left: 0; +} + +.datetimeshortcuts .clock-icon, .datetimeshortcuts .date-icon { + position: relative; + display: inline-block; + vertical-align: middle; + height: 16px; + width: 16px; + overflow: hidden; +} + +.datetimeshortcuts .clock-icon { + background: url(../img/icon-clock.svg) 0 0 no-repeat; +} + +.datetimeshortcuts a:focus .clock-icon, +.datetimeshortcuts a:hover .clock-icon { + background-position: 0 -16px; +} + +.datetimeshortcuts .date-icon { + background: url(../img/icon-calendar.svg) 0 0 no-repeat; + top: -1px; +} + +.datetimeshortcuts a:focus .date-icon, +.datetimeshortcuts a:hover .date-icon { + background-position: 0 -16px; +} + +.timezonewarning { + font-size: 0.6875rem; + color: var(--body-quiet-color); +} + +/* URL */ + +p.url { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.url a { + font-weight: normal; +} + +/* FILE UPLOADS */ + +p.file-upload { + line-height: 20px; + margin: 0; + padding: 0; + color: var(--body-quiet-color); + font-size: 0.6875rem; + font-weight: bold; +} + +.file-upload a { + font-weight: normal; +} + +.file-upload .deletelink { + margin-left: 5px; +} + +span.clearable-file-input label { + color: var(--body-fg); + font-size: 0.6875rem; + display: inline; + float: none; +} + +/* CALENDARS & CLOCKS */ + +.calendarbox, .clockbox { + margin: 5px auto; + font-size: 0.75rem; + width: 19em; + text-align: center; + background: var(--body-bg); + color: var(--body-fg); + border: 1px solid var(--hairline-color); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + overflow: hidden; + position: relative; +} + +.clockbox { + width: auto; +} + +.calendar { + margin: 0; + padding: 0; +} + +.calendar table { + margin: 0; + padding: 0; + border-collapse: collapse; + background: white; + width: 100%; +} + +.calendar caption, .calendarbox h2 { + margin: 0; + text-align: center; + border-top: none; + font-weight: 700; + font-size: 0.75rem; + color: #333; + background: var(--accent); +} + +.calendar th { + padding: 8px 5px; + background: var(--darkened-bg); + border-bottom: 1px solid var(--border-color); + font-weight: 400; + font-size: 0.75rem; + text-align: center; + color: var(--body-quiet-color); +} + +.calendar td { + font-weight: 400; + font-size: 0.75rem; + text-align: center; + padding: 0; + border-top: 1px solid var(--hairline-color); + border-bottom: none; +} + +.calendar td.selected a { + background: var(--primary); + color: var(--button-fg); +} + +.calendar td.nonday { + background: var(--darkened-bg); +} + +.calendar td.today a { + font-weight: 700; +} + +.calendar td a, .timelist a { + display: block; + font-weight: 400; + padding: 6px; + text-decoration: none; + color: var(--body-quiet-color); +} + +.calendar td a:focus, .timelist a:focus, +.calendar td a:hover, .timelist a:hover { + background: var(--primary); + color: white; +} + +.calendar td a:active, .timelist a:active { + background: var(--header-bg); + color: white; +} + +.calendarnav { + font-size: 0.625rem; + text-align: center; + color: #ccc; + margin: 0; + padding: 1px 3px; +} + +.calendarnav a:link, #calendarnav a:visited, +#calendarnav a:focus, #calendarnav a:hover { + color: var(--body-quiet-color); +} + +.calendar-shortcuts { + background: var(--body-bg); + color: var(--body-quiet-color); + font-size: 0.6875rem; + line-height: 0.6875rem; + border-top: 1px solid var(--hairline-color); + padding: 8px 0; +} + +.calendarbox .calendarnav-previous, .calendarbox .calendarnav-next { + display: block; + position: absolute; + top: 8px; + width: 15px; + height: 15px; + text-indent: -9999px; + padding: 0; +} + +.calendarnav-previous { + left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -15px; +} + +.calendarnav-next { + right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -45px; +} + +.calendar-cancel { + margin: 0; + padding: 4px 0; + font-size: 0.75rem; + background: #eee; + border-top: 1px solid var(--border-color); + color: var(--body-fg); +} + +.calendar-cancel:focus, .calendar-cancel:hover { + background: #ddd; +} + +.calendar-cancel a { + color: black; + display: block; +} + +ul.timelist, .timelist li { + list-style-type: none; + margin: 0; + padding: 0; +} + +.timelist a { + padding: 2px; +} + +/* EDIT INLINE */ + +.inline-deletelink { + float: right; + text-indent: -9999px; + background: url(../img/inline-delete.svg) 0 0 no-repeat; + width: 16px; + height: 16px; + border: 0px none; +} + +.inline-deletelink:focus, .inline-deletelink:hover { + cursor: pointer; +} + +/* RELATED WIDGET WRAPPER */ +.related-widget-wrapper { + float: left; /* display properly in form rows with multiple fields */ + overflow: hidden; /* clear floated contents */ +} + +.related-widget-wrapper-link { + opacity: 0.3; +} + +.related-widget-wrapper-link:link { + opacity: .8; +} + +.related-widget-wrapper-link:link:focus, +.related-widget-wrapper-link:link:hover { + opacity: 1; +} + +select + .related-widget-wrapper-link, +.related-widget-wrapper-link + .related-widget-wrapper-link { + margin-left: 7px; +} + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/static/img/LICENSE b/static/img/LICENSE new file mode 100644 index 0000000..a4faaa1 --- /dev/null +++ b/static/img/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2014 Code Charm Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/img/README.txt b/static/img/README.txt new file mode 100644 index 0000000..4eb2e49 --- /dev/null +++ b/static/img/README.txt @@ -0,0 +1,7 @@ +All icons are taken from Font Awesome (http://fontawesome.io/) project. +The Font Awesome font is licensed under the SIL OFL 1.1: +- https://scripts.sil.org/OFL + +SVG icons source: https://github.com/encharm/Font-Awesome-SVG-PNG +Font-Awesome-SVG-PNG is licensed under the MIT license (see file license +in current folder). diff --git a/static/img/calendar-icons.svg b/static/img/calendar-icons.svg new file mode 100644 index 0000000..dbf21c3 --- /dev/null +++ b/static/img/calendar-icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/static/img/gis/move_vertex_off.svg b/static/img/gis/move_vertex_off.svg new file mode 100644 index 0000000..228854f --- /dev/null +++ b/static/img/gis/move_vertex_off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/gis/move_vertex_on.svg b/static/img/gis/move_vertex_on.svg new file mode 100644 index 0000000..96b87fd --- /dev/null +++ b/static/img/gis/move_vertex_on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/icon-addlink.svg b/static/img/icon-addlink.svg new file mode 100644 index 0000000..e004fb1 --- /dev/null +++ b/static/img/icon-addlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-alert.svg b/static/img/icon-alert.svg new file mode 100644 index 0000000..e51ea83 --- /dev/null +++ b/static/img/icon-alert.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-calendar.svg b/static/img/icon-calendar.svg new file mode 100644 index 0000000..97910a9 --- /dev/null +++ b/static/img/icon-calendar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/img/icon-changelink.svg b/static/img/icon-changelink.svg new file mode 100644 index 0000000..bbb137a --- /dev/null +++ b/static/img/icon-changelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-clock.svg b/static/img/icon-clock.svg new file mode 100644 index 0000000..bf9985d --- /dev/null +++ b/static/img/icon-clock.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/img/icon-deletelink.svg b/static/img/icon-deletelink.svg new file mode 100644 index 0000000..4059b15 --- /dev/null +++ b/static/img/icon-deletelink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-no.svg b/static/img/icon-no.svg new file mode 100644 index 0000000..2e0d383 --- /dev/null +++ b/static/img/icon-no.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-unknown-alt.svg b/static/img/icon-unknown-alt.svg new file mode 100644 index 0000000..1c6b99f --- /dev/null +++ b/static/img/icon-unknown-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-unknown.svg b/static/img/icon-unknown.svg new file mode 100644 index 0000000..50b4f97 --- /dev/null +++ b/static/img/icon-unknown.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-viewlink.svg b/static/img/icon-viewlink.svg new file mode 100644 index 0000000..a1ca1d3 --- /dev/null +++ b/static/img/icon-viewlink.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icon-yes.svg b/static/img/icon-yes.svg new file mode 100644 index 0000000..5883d87 --- /dev/null +++ b/static/img/icon-yes.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/icons/bag.png b/static/img/icons/bag.png new file mode 100644 index 0000000..feae0c6 Binary files /dev/null and b/static/img/icons/bag.png differ diff --git a/static/img/icons/bag_25x25.png b/static/img/icons/bag_25x25.png new file mode 100644 index 0000000..b035cff Binary files /dev/null and b/static/img/icons/bag_25x25.png differ diff --git a/static/img/icons/money.png b/static/img/icons/money.png new file mode 100644 index 0000000..1283c97 Binary files /dev/null and b/static/img/icons/money.png differ diff --git a/static/img/icons/money_12x12.png b/static/img/icons/money_12x12.png new file mode 100644 index 0000000..9b10ff0 Binary files /dev/null and b/static/img/icons/money_12x12.png differ diff --git a/static/img/icons/money_25x25.png b/static/img/icons/money_25x25.png new file mode 100644 index 0000000..abcc050 Binary files /dev/null and b/static/img/icons/money_25x25.png differ diff --git a/static/img/icons/shield-green-v.png b/static/img/icons/shield-green-v.png new file mode 100644 index 0000000..8bb0ea4 Binary files /dev/null and b/static/img/icons/shield-green-v.png differ diff --git a/static/img/icons/shield-red-cross.png b/static/img/icons/shield-red-cross.png new file mode 100644 index 0000000..67a6604 Binary files /dev/null and b/static/img/icons/shield-red-cross.png differ diff --git a/static/img/inline-delete.svg b/static/img/inline-delete.svg new file mode 100644 index 0000000..17d1ad6 --- /dev/null +++ b/static/img/inline-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/maintenance/maintenance.jpeg b/static/img/maintenance/maintenance.jpeg new file mode 100644 index 0000000..b484c8d Binary files /dev/null and b/static/img/maintenance/maintenance.jpeg differ diff --git a/static/img/retrobot.jpg b/static/img/retrobot.jpg new file mode 100644 index 0000000..c4a17b4 Binary files /dev/null and b/static/img/retrobot.jpg differ diff --git a/static/img/retrobot.png b/static/img/retrobot.png new file mode 100644 index 0000000..7898d8e Binary files /dev/null and b/static/img/retrobot.png differ diff --git a/static/img/search.svg b/static/img/search.svg new file mode 100644 index 0000000..c8c69b2 --- /dev/null +++ b/static/img/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/selector-icons.svg b/static/img/selector-icons.svg new file mode 100644 index 0000000..926b8e2 --- /dev/null +++ b/static/img/selector-icons.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/sorting-icons.svg b/static/img/sorting-icons.svg new file mode 100644 index 0000000..7c31ec9 --- /dev/null +++ b/static/img/sorting-icons.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/static/img/tooltag-add.svg b/static/img/tooltag-add.svg new file mode 100644 index 0000000..1ca64ae --- /dev/null +++ b/static/img/tooltag-add.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/img/tooltag-arrowright.svg b/static/img/tooltag-arrowright.svg new file mode 100644 index 0000000..b664d61 --- /dev/null +++ b/static/img/tooltag-arrowright.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/js/SelectBox.js b/static/js/SelectBox.js new file mode 100644 index 0000000..3db4ec7 --- /dev/null +++ b/static/js/SelectBox.js @@ -0,0 +1,116 @@ +'use strict'; +{ + const SelectBox = { + cache: {}, + init: function(id) { + const box = document.getElementById(id); + SelectBox.cache[id] = []; + const cache = SelectBox.cache[id]; + for (const node of box.options) { + cache.push({value: node.value, text: node.text, displayed: 1}); + } + }, + redisplay: function(id) { + // Repopulate HTML select box from cache + const box = document.getElementById(id); + const scroll_value_from_top = box.scrollTop; + box.innerHTML = ''; + for (const node of SelectBox.cache[id]) { + if (node.displayed) { + const new_option = new Option(node.text, node.value, false, false); + // Shows a tooltip when hovering over the option + new_option.title = node.text; + box.appendChild(new_option); + } + } + box.scrollTop = scroll_value_from_top; + }, + filter: function(id, text) { + // Redisplay the HTML select box, displaying only the choices containing ALL + // the words in text. (It's an AND search.) + const tokens = text.toLowerCase().split(/\s+/); + for (const node of SelectBox.cache[id]) { + node.displayed = 1; + const node_text = node.text.toLowerCase(); + for (const token of tokens) { + if (!node_text.includes(token)) { + node.displayed = 0; + break; // Once the first token isn't found we're done + } + } + } + SelectBox.redisplay(id); + }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, + delete_from_cache: function(id, value) { + let delete_index = null; + const cache = SelectBox.cache[id]; + for (const [i, node] of cache.entries()) { + if (node.value === value) { + delete_index = i; + break; + } + } + cache.splice(delete_index, 1); + }, + add_to_cache: function(id, option) { + SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1}); + }, + cache_contains: function(id, value) { + // Check if an item is contained in the cache + for (const node of SelectBox.cache[id]) { + if (node.value === value) { + return true; + } + } + return false; + }, + move: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (option.selected && SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + move_all: function(from, to) { + const from_box = document.getElementById(from); + for (const option of from_box.options) { + const option_value = option.value; + if (SelectBox.cache_contains(from, option_value)) { + SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1}); + SelectBox.delete_from_cache(from, option_value); + } + } + SelectBox.redisplay(from); + SelectBox.redisplay(to); + }, + sort: function(id) { + SelectBox.cache[id].sort(function(a, b) { + a = a.text.toLowerCase(); + b = b.text.toLowerCase(); + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + return 0; + } ); + }, + select_all: function(id) { + const box = document.getElementById(id); + for (const option of box.options) { + option.selected = true; + } + } + }; + window.SelectBox = SelectBox; +} diff --git a/static/js/SelectFilter2.js b/static/js/SelectFilter2.js new file mode 100644 index 0000000..9a4e0a3 --- /dev/null +++ b/static/js/SelectFilter2.js @@ -0,0 +1,283 @@ +/*global SelectBox, gettext, interpolate, quickElement, SelectFilter*/ +/* +SelectFilter2 - Turns a multiple-select box into a filter interface. + +Requires core.js and SelectBox.js. +*/ +'use strict'; +{ + window.SelectFilter = { + init: function(field_id, field_name, is_stacked) { + if (field_id.match(/__prefix__/)) { + // Don't initialize on empty forms. + return; + } + const from_box = document.getElementById(field_id); + from_box.id += '_from'; // change its ID + from_box.className = 'filtered'; + + for (const p of from_box.parentNode.getElementsByTagName('p')) { + if (p.classList.contains("info")) { + // Remove

    , because it just gets in the way. + from_box.parentNode.removeChild(p); + } else if (p.classList.contains("help")) { + // Move help text up to the top so it isn't below the select + // boxes or wrapped off on the side to the right of the add + // button: + from_box.parentNode.insertBefore(p, from_box.parentNode.firstChild); + } + } + + //

    or
    + const selector_div = quickElement('div', from_box.parentNode); + selector_div.className = is_stacked ? 'selector stacked' : 'selector'; + + //
    + const selector_available = quickElement('div', selector_div); + selector_available.className = 'selector-available'; + const title_available = quickElement('h2', selector_available, interpolate(gettext('Available %s') + ' ', [field_name])); + quickElement( + 'span', title_available, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of available %s. You may choose some by ' + + 'selecting them in the box below and then clicking the ' + + '"Choose" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_p = quickElement('p', selector_available, '', 'id', field_id + '_filter'); + filter_p.className = 'selector-filter'; + + const search_filter_label = quickElement('label', filter_p, '', 'for', field_id + '_input'); + + quickElement( + 'span', search_filter_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of available %s."), [field_name]) + ); + + filter_p.appendChild(document.createTextNode(' ')); + + const filter_input = quickElement('input', filter_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_input.id = field_id + '_input'; + + selector_available.appendChild(from_box); + const choose_all = quickElement('a', selector_available, gettext('Choose all'), 'title', interpolate(gettext('Click to choose all %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_add_all_link'); + choose_all.className = 'selector-chooseall'; + + //
      + const selector_chooser = quickElement('ul', selector_div); + selector_chooser.className = 'selector-chooser'; + const add_link = quickElement('a', quickElement('li', selector_chooser), gettext('Choose'), 'title', gettext('Choose'), 'href', '#', 'id', field_id + '_add_link'); + add_link.className = 'selector-add'; + const remove_link = quickElement('a', quickElement('li', selector_chooser), gettext('Remove'), 'title', gettext('Remove'), 'href', '#', 'id', field_id + '_remove_link'); + remove_link.className = 'selector-remove'; + + //
      + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); + selector_chosen.className = 'selector-chosen'; + const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); + quickElement( + 'span', title_chosen, '', + 'class', 'help help-tooltip help-icon', + 'title', interpolate( + gettext( + 'This is the list of chosen %s. You may remove some by ' + + 'selecting them in the box below and then clicking the ' + + '"Remove" arrow between the two boxes.' + ), + [field_name] + ) + ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; + + const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); + to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); + clear_all.className = 'selector-clearall'; + + from_box.name = from_box.name + '_old'; + + // Set up the JavaScript event handlers for the select box filter interface + const move_selection = function(e, elem, move_func, from, to) { + if (elem.classList.contains('active')) { + move_func(from, to); + SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + } + e.preventDefault(); + }; + choose_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_from', field_id + '_to'); + }); + add_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_from', field_id + '_to'); + }); + remove_link.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move, field_id + '_to', field_id + '_from'); + }); + clear_all.addEventListener('click', function(e) { + move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); + }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); + filter_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); + }); + filter_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_from'); + }); + filter_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); + }); + selector_div.addEventListener('change', function(e) { + if (e.target.tagName === 'SELECT') { + SelectFilter.refresh_icons(field_id); + } + }); + selector_div.addEventListener('dblclick', function(e) { + if (e.target.tagName === 'OPTION') { + if (e.target.closest('select').id === field_id + '_to') { + SelectBox.move(field_id + '_to', field_id + '_from'); + } else { + SelectBox.move(field_id + '_from', field_id + '_to'); + } + SelectFilter.refresh_icons(field_id); + } + }); + from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); + SelectBox.select_all(field_id + '_to'); + }); + SelectBox.init(field_id + '_from'); + SelectBox.init(field_id + '_to'); + // Move selected from_box options to to_box + SelectBox.move(field_id + '_from', field_id + '_to'); + + // Initial icon refresh + SelectFilter.refresh_icons(field_id); + }, + any_selected: function(field) { + // Temporarily add the required attribute and check validity. + field.required = true; + const any_selected = field.checkValidity(); + field.required = false; + return any_selected; + }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, + refresh_icons: function(field_id) { + const from = document.getElementById(field_id + '_from'); + const to = document.getElementById(field_id + '_to'); + // Active if at least one item is selected + document.getElementById(field_id + '_add_link').classList.toggle('active', SelectFilter.any_selected(from)); + document.getElementById(field_id + '_remove_link').classList.toggle('active', SelectFilter.any_selected(to)); + // Active if the corresponding box isn't empty + document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); + document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); + }, + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // don't submit form if user pressed Enter + if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; + event.preventDefault(); + } + }, + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }, + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; + // right arrow -- move across + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; + return; + } + // down arrow -- wrap around + if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; + } + // up arrow -- wrap around + if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; + } + } + }; + + window.addEventListener('load', function(e) { + document.querySelectorAll('select.selectfilter, select.selectfilterstacked').forEach(function(el) { + const data = el.dataset; + SelectFilter.init(el.id, data.fieldName, parseInt(data.isStacked, 10)); + }); + }); +} diff --git a/static/js/actions.js b/static/js/actions.js new file mode 100644 index 0000000..20a5c14 --- /dev/null +++ b/static/js/actions.js @@ -0,0 +1,201 @@ +/*global gettext, interpolate, ngettext*/ +'use strict'; +{ + function show(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.remove('hidden'); + }); + } + + function hide(selector) { + document.querySelectorAll(selector).forEach(function(el) { + el.classList.add('hidden'); + }); + } + + function showQuestion(options) { + hide(options.acrossClears); + show(options.acrossQuestions); + hide(options.allContainer); + } + + function showClear(options) { + show(options.acrossClears); + hide(options.acrossQuestions); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + show(options.allContainer); + hide(options.counterContainer); + } + + function reset(options) { + hide(options.acrossClears); + hide(options.acrossQuestions); + hide(options.allContainer); + show(options.counterContainer); + } + + function clearAcross(options) { + reset(options); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 0; + }); + document.querySelector(options.actionContainer).classList.remove(options.selectedClass); + } + + function checker(actionCheckboxes, options, checked) { + if (checked) { + showQuestion(options); + } else { + reset(options); + } + actionCheckboxes.forEach(function(el) { + el.checked = checked; + el.closest('tr').classList.toggle(options.selectedClass, checked); + }); + } + + function updateCounter(actionCheckboxes, options) { + const sel = Array.from(actionCheckboxes).filter(function(el) { + return el.checked; + }).length; + const counter = document.querySelector(options.counterContainer); + // data-actions-icnt is defined in the generated HTML + // and contains the total amount of objects in the queryset + const actions_icnt = Number(counter.dataset.actionsIcnt); + counter.textContent = interpolate( + ngettext('%(sel)s of %(cnt)s selected', '%(sel)s of %(cnt)s selected', sel), { + sel: sel, + cnt: actions_icnt + }, true); + const allToggle = document.getElementById(options.allToggleId); + allToggle.checked = sel === actionCheckboxes.length; + if (allToggle.checked) { + showQuestion(options); + } else { + clearAcross(options); + } + } + + const defaults = { + actionContainer: "div.actions", + counterContainer: "span.action-counter", + allContainer: "div.actions span.all", + acrossInput: "div.actions input.select-across", + acrossQuestions: "div.actions span.question", + acrossClears: "div.actions span.clear", + allToggleId: "action-toggle", + selectedClass: "selected" + }; + + window.Actions = function(actionCheckboxes, options) { + options = Object.assign({}, defaults, options); + let list_editable_changed = false; + let lastChecked = null; + let shiftPressed = false; + + document.addEventListener('keydown', (event) => { + shiftPressed = event.shiftKey; + }); + + document.addEventListener('keyup', (event) => { + shiftPressed = event.shiftKey; + }); + + document.getElementById(options.allToggleId).addEventListener('click', function(event) { + checker(actionCheckboxes, options, this.checked); + updateCounter(actionCheckboxes, options); + }); + + document.querySelectorAll(options.acrossQuestions + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + const acrossInputs = document.querySelectorAll(options.acrossInput); + acrossInputs.forEach(function(acrossInput) { + acrossInput.value = 1; + }); + showClear(options); + }); + }); + + document.querySelectorAll(options.acrossClears + " a").forEach(function(el) { + el.addEventListener('click', function(event) { + event.preventDefault(); + document.getElementById(options.allToggleId).checked = false; + clearAcross(options); + checker(actionCheckboxes, options, false); + updateCounter(actionCheckboxes, options); + }); + }); + + function affectedCheckboxes(target, withModifier) { + const multiSelect = (lastChecked && withModifier && lastChecked !== target); + if (!multiSelect) { + return [target]; + } + const checkboxes = Array.from(actionCheckboxes); + const targetIndex = checkboxes.findIndex(el => el === target); + const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked); + const startIndex = Math.min(targetIndex, lastCheckedIndex); + const endIndex = Math.max(targetIndex, lastCheckedIndex); + const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex)); + return filtered; + }; + + Array.from(document.getElementById('result_list').tBodies).forEach(function(el) { + el.addEventListener('change', function(event) { + const target = event.target; + if (target.classList.contains('action-select')) { + const checkboxes = affectedCheckboxes(target, shiftPressed); + checker(checkboxes, options, target.checked); + updateCounter(actionCheckboxes, options); + lastChecked = target; + } else { + list_editable_changed = true; + } + }); + }); + + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { + if (list_editable_changed) { + const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); + if (!confirmed) { + event.preventDefault(); + } + } + }); + + const el = document.querySelector('#changelist-form input[name=_save]'); + // The button does not exist if no fields are editable. + if (el) { + el.addEventListener('click', function(event) { + if (document.querySelector('[name=action]').value) { + const text = list_editable_changed + ? gettext("You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action.") + : gettext("You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."); + if (!confirm(text)) { + event.preventDefault(); + } + } + }); + } + }; + + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + const actionsEls = document.querySelectorAll('tr input.action-select'); + if (actionsEls.length > 0) { + Actions(actionsEls); + } + }); +} diff --git a/static/js/admin/DateTimeShortcuts.js b/static/js/admin/DateTimeShortcuts.js new file mode 100644 index 0000000..aa1cae9 --- /dev/null +++ b/static/js/admin/DateTimeShortcuts.js @@ -0,0 +1,408 @@ +/*global Calendar, findPosX, findPosY, get_format, gettext, gettext_noop, interpolate, ngettext, quickElement*/ +// Inserts shortcut buttons after all of the following: +// +// +'use strict'; +{ + const DateTimeShortcuts = { + calendars: [], + calendarInputs: [], + clockInputs: [], + clockHours: { + default_: [ + [gettext_noop('Now'), -1], + [gettext_noop('Midnight'), 0], + [gettext_noop('6 a.m.'), 6], + [gettext_noop('Noon'), 12], + [gettext_noop('6 p.m.'), 18] + ] + }, + dismissClockFunc: [], + dismissCalendarFunc: [], + calendarDivName1: 'calendarbox', // name of calendar
      that gets toggled + calendarDivName2: 'calendarin', // name of
      that contains calendar + calendarLinkName: 'calendarlink', // name of the link that is used to toggle + clockDivName: 'clockbox', // name of clock
      that gets toggled + clockLinkName: 'clocklink', // name of the link that is used to toggle + shortCutsClass: 'datetimeshortcuts', // class of the clock and cal shortcuts + timezoneWarningClass: 'timezonewarning', // class of the warning for timezone mismatch + timezoneOffset: 0, + init: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localOffset = new Date().getTimezoneOffset() * -60; + DateTimeShortcuts.timezoneOffset = localOffset - serverOffset; + } + + for (const inp of document.getElementsByTagName('input')) { + if (inp.type === 'text' && inp.classList.contains('vTimeField')) { + DateTimeShortcuts.addClock(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + else if (inp.type === 'text' && inp.classList.contains('vDateField')) { + DateTimeShortcuts.addCalendar(inp); + DateTimeShortcuts.addTimezoneWarning(inp); + } + } + }, + // Return the current time while accounting for the server timezone. + now: function() { + const serverOffset = document.body.dataset.adminUtcOffset; + if (serverOffset) { + const localNow = new Date(); + const localOffset = localNow.getTimezoneOffset() * -60; + localNow.setTime(localNow.getTime() + 1000 * (serverOffset - localOffset)); + return localNow; + } else { + return new Date(); + } + }, + // Add a warning when the time zone in the browser and backend do not match. + addTimezoneWarning: function(inp) { + const warningClass = DateTimeShortcuts.timezoneWarningClass; + let timezoneOffset = DateTimeShortcuts.timezoneOffset / 3600; + + // Only warn if there is a time zone mismatch. + if (!timezoneOffset) { + return; + } + + // Check if warning is already there. + if (inp.parentNode.querySelectorAll('.' + warningClass).length) { + return; + } + + let message; + if (timezoneOffset > 0) { + message = ngettext( + 'Note: You are %s hour ahead of server time.', + 'Note: You are %s hours ahead of server time.', + timezoneOffset + ); + } + else { + timezoneOffset *= -1; + message = ngettext( + 'Note: You are %s hour behind server time.', + 'Note: You are %s hours behind server time.', + timezoneOffset + ); + } + message = interpolate(message, [timezoneOffset]); + + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); + warning.textContent = message; + inp.parentNode.appendChild(warning); + }, + // Add clock widget to a given field + addClock: function(inp) { + const num = DateTimeShortcuts.clockInputs.length; + DateTimeShortcuts.clockInputs[num] = inp; + DateTimeShortcuts.dismissClockFunc[num] = function() { DateTimeShortcuts.dismissClock(num); return true; }; + + // Shortcut links (clock icon and "Now" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const now_link = document.createElement('a'); + now_link.href = "#"; + now_link.textContent = gettext('Now'); + now_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, -1); + }); + const clock_link = document.createElement('a'); + clock_link.href = '#'; + clock_link.id = DateTimeShortcuts.clockLinkName + num; + clock_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the clock + e.stopPropagation(); + DateTimeShortcuts.openClock(num); + }); + + quickElement( + 'span', clock_link, '', + 'class', 'clock-icon', + 'title', gettext('Choose a Time') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(now_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(clock_link); + + // Create clock link div + // + // Markup looks like: + //
      + //

      Choose a time

      + // + //

      Cancel

      + //
      + + const clock_box = document.createElement('div'); + clock_box.style.display = 'none'; + clock_box.style.position = 'absolute'; + clock_box.className = 'clockbox module'; + clock_box.id = DateTimeShortcuts.clockDivName + num; + document.body.appendChild(clock_box); + clock_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + quickElement('h2', clock_box, gettext('Choose a time')); + const time_list = quickElement('ul', clock_box); + time_list.className = 'timelist'; + // The list of choices can be overridden in JavaScript like this: + // DateTimeShortcuts.clockHours.name = [['3 a.m.', 3]]; + // where name is the name attribute of the . + const name = typeof DateTimeShortcuts.clockHours[inp.name] === 'undefined' ? 'default_' : inp.name; + DateTimeShortcuts.clockHours[name].forEach(function(element) { + const time_link = quickElement('a', quickElement('li', time_list), gettext(element[0]), 'href', '#'); + time_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleClockQuicklink(num, element[1]); + }); + }); + + const cancel_p = quickElement('p', clock_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissClock(num); + }); + + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissClock(num); + event.preventDefault(); + } + }); + }, + openClock: function(num) { + const clock_box = document.getElementById(DateTimeShortcuts.clockDivName + num); + const clock_link = document.getElementById(DateTimeShortcuts.clockLinkName + num); + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + clock_box.style.left = findPosX(clock_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + clock_box.style.left = findPosX(clock_link) - 110 + 'px'; + } + clock_box.style.top = Math.max(0, findPosY(clock_link) - 30) + 'px'; + + // Show the clock box + clock_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + dismissClock: function(num) { + document.getElementById(DateTimeShortcuts.clockDivName + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissClockFunc[num]); + }, + handleClockQuicklink: function(num, val) { + let d; + if (val === -1) { + d = DateTimeShortcuts.now(); + } + else { + d = new Date(1970, 1, 1, val, 0, 0, 0); + } + DateTimeShortcuts.clockInputs[num].value = d.strftime(get_format('TIME_INPUT_FORMATS')[0]); + DateTimeShortcuts.clockInputs[num].focus(); + DateTimeShortcuts.dismissClock(num); + }, + // Add calendar widget to a given field. + addCalendar: function(inp) { + const num = DateTimeShortcuts.calendars.length; + + DateTimeShortcuts.calendarInputs[num] = inp; + DateTimeShortcuts.dismissCalendarFunc[num] = function() { DateTimeShortcuts.dismissCalendar(num); return true; }; + + // Shortcut links (calendar icon and "Today" link) + const shortcuts_span = document.createElement('span'); + shortcuts_span.className = DateTimeShortcuts.shortCutsClass; + inp.parentNode.insertBefore(shortcuts_span, inp.nextSibling); + const today_link = document.createElement('a'); + today_link.href = '#'; + today_link.appendChild(document.createTextNode(gettext('Today'))); + today_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + const cal_link = document.createElement('a'); + cal_link.href = '#'; + cal_link.id = DateTimeShortcuts.calendarLinkName + num; + cal_link.addEventListener('click', function(e) { + e.preventDefault(); + // avoid triggering the document click handler to dismiss the calendar + e.stopPropagation(); + DateTimeShortcuts.openCalendar(num); + }); + quickElement( + 'span', cal_link, '', + 'class', 'date-icon', + 'title', gettext('Choose a Date') + ); + shortcuts_span.appendChild(document.createTextNode('\u00A0')); + shortcuts_span.appendChild(today_link); + shortcuts_span.appendChild(document.createTextNode('\u00A0|\u00A0')); + shortcuts_span.appendChild(cal_link); + + // Create calendarbox div. + // + // Markup looks like: + // + //
      + //

      + // + // February 2003 + //

      + //
      + // + //
      + //
      + // Yesterday | Today | Tomorrow + //
      + //

      Cancel

      + //
      + const cal_box = document.createElement('div'); + cal_box.style.display = 'none'; + cal_box.style.position = 'absolute'; + cal_box.className = 'calendarbox module'; + cal_box.id = DateTimeShortcuts.calendarDivName1 + num; + document.body.appendChild(cal_box); + cal_box.addEventListener('click', function(e) { e.stopPropagation(); }); + + // next-prev links + const cal_nav = quickElement('div', cal_box); + const cal_nav_prev = quickElement('a', cal_nav, '<', 'href', '#'); + cal_nav_prev.className = 'calendarnav-previous'; + cal_nav_prev.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawPrev(num); + }); + + const cal_nav_next = quickElement('a', cal_nav, '>', 'href', '#'); + cal_nav_next.className = 'calendarnav-next'; + cal_nav_next.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.drawNext(num); + }); + + // main box + const cal_main = quickElement('div', cal_box, '', 'id', DateTimeShortcuts.calendarDivName2 + num); + cal_main.className = 'calendar'; + DateTimeShortcuts.calendars[num] = new Calendar(DateTimeShortcuts.calendarDivName2 + num, DateTimeShortcuts.handleCalendarCallback(num)); + DateTimeShortcuts.calendars[num].drawCurrent(); + + // calendar shortcuts + const shortcuts = quickElement('div', cal_box); + shortcuts.className = 'calendar-shortcuts'; + let day_link = quickElement('a', shortcuts, gettext('Yesterday'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, -1); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Today'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, 0); + }); + shortcuts.appendChild(document.createTextNode('\u00A0|\u00A0')); + day_link = quickElement('a', shortcuts, gettext('Tomorrow'), 'href', '#'); + day_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.handleCalendarQuickLink(num, +1); + }); + + // cancel bar + const cancel_p = quickElement('p', cal_box); + cancel_p.className = 'calendar-cancel'; + const cancel_link = quickElement('a', cancel_p, gettext('Cancel'), 'href', '#'); + cancel_link.addEventListener('click', function(e) { + e.preventDefault(); + DateTimeShortcuts.dismissCalendar(num); + }); + document.addEventListener('keyup', function(event) { + if (event.which === 27) { + // ESC key closes popup + DateTimeShortcuts.dismissCalendar(num); + event.preventDefault(); + } + }); + }, + openCalendar: function(num) { + const cal_box = document.getElementById(DateTimeShortcuts.calendarDivName1 + num); + const cal_link = document.getElementById(DateTimeShortcuts.calendarLinkName + num); + const inp = DateTimeShortcuts.calendarInputs[num]; + + // Determine if the current value in the input has a valid date. + // If so, draw the calendar with that date's year and month. + if (inp.value) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + const selected = inp.value.strptime(format); + const year = selected.getUTCFullYear(); + const month = selected.getUTCMonth() + 1; + const re = /\d{4}/; + if (re.test(year.toString()) && month >= 1 && month <= 12) { + DateTimeShortcuts.calendars[num].drawDate(month, year, selected); + } + } + + // Recalculate the clockbox position + // is it left-to-right or right-to-left layout ? + if (window.getComputedStyle(document.body).direction !== 'rtl') { + cal_box.style.left = findPosX(cal_link) + 17 + 'px'; + } + else { + // since style's width is in em, it'd be tough to calculate + // px value of it. let's use an estimated px for now + cal_box.style.left = findPosX(cal_link) - 180 + 'px'; + } + cal_box.style.top = Math.max(0, findPosY(cal_link) - 75) + 'px'; + + cal_box.style.display = 'block'; + document.addEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + dismissCalendar: function(num) { + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + document.removeEventListener('click', DateTimeShortcuts.dismissCalendarFunc[num]); + }, + drawPrev: function(num) { + DateTimeShortcuts.calendars[num].drawPreviousMonth(); + }, + drawNext: function(num) { + DateTimeShortcuts.calendars[num].drawNextMonth(); + }, + handleCalendarCallback: function(num) { + const format = get_format('DATE_INPUT_FORMATS')[0]; + return function(y, m, d) { + DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); + DateTimeShortcuts.calendarInputs[num].focus(); + document.getElementById(DateTimeShortcuts.calendarDivName1 + num).style.display = 'none'; + }; + }, + handleCalendarQuickLink: function(num, offset) { + const d = DateTimeShortcuts.now(); + d.setDate(d.getDate() + offset); + DateTimeShortcuts.calendarInputs[num].value = d.strftime(get_format('DATE_INPUT_FORMATS')[0]); + DateTimeShortcuts.calendarInputs[num].focus(); + DateTimeShortcuts.dismissCalendar(num); + } + }; + + window.addEventListener('load', DateTimeShortcuts.init); + window.DateTimeShortcuts = DateTimeShortcuts; +} diff --git a/static/js/admin/RelatedObjectLookups.js b/static/js/admin/RelatedObjectLookups.js new file mode 100644 index 0000000..afb6b66 --- /dev/null +++ b/static/js/admin/RelatedObjectLookups.js @@ -0,0 +1,238 @@ +/*global SelectBox, interpolate*/ +// Handles related-objects functionality: lookup link for raw_id_fields +// and Add Another links. +'use strict'; +{ + const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } + + function showAdminPopup(triggeringLink, name_regexp, add_popup) { + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); + const href = new URL(triggeringLink.href); + if (add_popup) { + href.searchParams.set('_popup', 1); + } + const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); + win.focus(); + return false; + } + + function showRelatedObjectLookupPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^lookup_/, true); + } + + function dismissRelatedLookupPopup(win, chosenId) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + chosenId; + } else { + document.getElementById(name).value = chosenId; + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function showRelatedObjectPopup(triggeringLink) { + return showAdminPopup(triggeringLink, /^(change|add|delete)_/, false); + } + + function updateRelatedObjectLinks(triggeringLink) { + const $this = $(triggeringLink); + const siblings = $this.nextAll('.view-related, .change-related, .delete-related'); + if (!siblings.length) { + return; + } + const value = $this.val(); + if (value) { + siblings.each(function() { + const elm = $(this); + elm.attr('href', elm.attr('data-href-template').replace('__fk__', value)); + }); + } else { + siblings.removeAttr('href'); + } + } + + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + + function dismissAddRelatedObjectPopup(win, newId, newRepr) { + const name = removePopupIndex(win.name); + const elem = document.getElementById(name); + if (elem) { + const elemName = elem.nodeName.toUpperCase(); + if (elemName === 'SELECT') { + elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); + } else if (elemName === 'INPUT') { + if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { + elem.value += ',' + newId; + } else { + elem.value = newId; + } + } + // Trigger a change event to update related links if required. + $(elem).trigger('change'); + } else { + const toId = name + "_to"; + const o = new Option(newRepr, newId); + SelectBox.add_to_cache(toId, o); + SelectBox.redisplay(toId); + } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { + const id = removePopupIndex(win.name.replace(/^edit_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + this.textContent = newRepr; + this.value = newId; + } + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); + selects.next().find('.select2-selection__rendered').each(function() { + // The element can have a clear button as a child. + // Use the lastChild to modify only the displayed value. + this.lastChild.textContent = newRepr; + this.title = newRepr; + }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + function dismissDeleteRelatedObjectPopup(win, objId) { + const id = removePopupIndex(win.name.replace(/^delete_/, '')); + const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); + const selects = $(selectsSelector); + selects.find('option').each(function() { + if (this.value === objId) { + $(this).remove(); + } + }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } + win.close(); + } + + window.showRelatedObjectLookupPopup = showRelatedObjectLookupPopup; + window.dismissRelatedLookupPopup = dismissRelatedLookupPopup; + window.showRelatedObjectPopup = showRelatedObjectPopup; + window.updateRelatedObjectLinks = updateRelatedObjectLinks; + window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; + window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; + window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; + + // Kept for backward compatibility + window.showAddAnotherPopup = showRelatedObjectPopup; + window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + + $(document).ready(function() { + setPopupIndex(); + $("a[data-popup-opener]").on('click', function(event) { + event.preventDefault(); + opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); + }); + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { + e.preventDefault(); + if (this.href) { + const event = $.Event('django:show-related', {href: this.href}); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectPopup(this); + } + } + }); + $('body').on('change', '.related-widget-wrapper select', function(e) { + const event = $.Event('django:update-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + updateRelatedObjectLinks(this); + } + }); + $('.related-widget-wrapper select').trigger('change'); + $('body').on('click', '.related-lookup', function(e) { + e.preventDefault(); + const event = $.Event('django:lookup-related'); + $(this).trigger(event); + if (!event.isDefaultPrevented()) { + showRelatedObjectLookupPopup(this); + } + }); + }); +} diff --git a/static/js/autocomplete.js b/static/js/autocomplete.js new file mode 100644 index 0000000..d3daeab --- /dev/null +++ b/static/js/autocomplete.js @@ -0,0 +1,33 @@ +'use strict'; +{ + const $ = django.jQuery; + + $.fn.djangoAdminSelect2 = function() { + $.each(this, function(i, element) { + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); + }); + return this; + }; + + $(function() { + // Initialize all autocomplete widgets except the one in the template + // form used when a new formset is added. + $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); + }); + + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); +} diff --git a/static/js/calendar.js b/static/js/calendar.js new file mode 100644 index 0000000..a62d10a --- /dev/null +++ b/static/js/calendar.js @@ -0,0 +1,221 @@ +/*global gettext, pgettext, get_format, quickElement, removeChildren*/ +/* +calendar.js - Calendar functions by Adrian Holovaty +depends on core.js for utility functions like removeChildren or quickElement +*/ +'use strict'; +{ + // CalendarNamespace -- Provides a collection of HTML calendar-related helper functions + const CalendarNamespace = { + monthsOfYear: [ + gettext('January'), + gettext('February'), + gettext('March'), + gettext('April'), + gettext('May'), + gettext('June'), + gettext('July'), + gettext('August'), + gettext('September'), + gettext('October'), + gettext('November'), + gettext('December') + ], + monthsOfYearAbbrev: [ + pgettext('abbrev. month January', 'Jan'), + pgettext('abbrev. month February', 'Feb'), + pgettext('abbrev. month March', 'Mar'), + pgettext('abbrev. month April', 'Apr'), + pgettext('abbrev. month May', 'May'), + pgettext('abbrev. month June', 'Jun'), + pgettext('abbrev. month July', 'Jul'), + pgettext('abbrev. month August', 'Aug'), + pgettext('abbrev. month September', 'Sep'), + pgettext('abbrev. month October', 'Oct'), + pgettext('abbrev. month November', 'Nov'), + pgettext('abbrev. month December', 'Dec') + ], + daysOfWeek: [ + pgettext('one letter Sunday', 'S'), + pgettext('one letter Monday', 'M'), + pgettext('one letter Tuesday', 'T'), + pgettext('one letter Wednesday', 'W'), + pgettext('one letter Thursday', 'T'), + pgettext('one letter Friday', 'F'), + pgettext('one letter Saturday', 'S') + ], + firstDayOfWeek: parseInt(get_format('FIRST_DAY_OF_WEEK')), + isLeapYear: function(year) { + return (((year % 4) === 0) && ((year % 100) !== 0 ) || ((year % 400) === 0)); + }, + getDaysInMonth: function(month, year) { + let days; + if (month === 1 || month === 3 || month === 5 || month === 7 || month === 8 || month === 10 || month === 12) { + days = 31; + } + else if (month === 4 || month === 6 || month === 9 || month === 11) { + days = 30; + } + else if (month === 2 && CalendarNamespace.isLeapYear(year)) { + days = 29; + } + else { + days = 28; + } + return days; + }, + draw: function(month, year, div_id, callback, selected) { // month = 1-12, year = 1-9999 + const today = new Date(); + const todayDay = today.getDate(); + const todayMonth = today.getMonth() + 1; + const todayYear = today.getFullYear(); + let todayClass = ''; + + // Use UTC functions here because the date field does not contain time + // and using the UTC function variants prevent the local time offset + // from altering the date, specifically the day field. For example: + // + // ``` + // var x = new Date('2013-10-02'); + // var day = x.getDate(); + // ``` + // + // The day variable above will be 1 instead of 2 in, say, US Pacific time + // zone. + let isSelectedMonth = false; + if (typeof selected !== 'undefined') { + isSelectedMonth = (selected.getUTCFullYear() === year && (selected.getUTCMonth() + 1) === month); + } + + month = parseInt(month); + year = parseInt(year); + const calDiv = document.getElementById(div_id); + removeChildren(calDiv); + const calTable = document.createElement('table'); + quickElement('caption', calTable, CalendarNamespace.monthsOfYear[month - 1] + ' ' + year); + const tableBody = quickElement('tbody', calTable); + + // Draw days-of-week header + let tableRow = quickElement('tr', tableBody); + for (let i = 0; i < 7; i++) { + quickElement('th', tableRow, CalendarNamespace.daysOfWeek[(i + CalendarNamespace.firstDayOfWeek) % 7]); + } + + const startingPos = new Date(year, month - 1, 1 - CalendarNamespace.firstDayOfWeek).getDay(); + const days = CalendarNamespace.getDaysInMonth(month, year); + + let nonDayCell; + + // Draw blanks before first of month + tableRow = quickElement('tr', tableBody); + for (let i = 0; i < startingPos; i++) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + function calendarMonth(y, m) { + function onClick(e) { + e.preventDefault(); + callback(y, m, this.textContent); + } + return onClick; + } + + // Draw days of month + let currentDay = 1; + for (let i = startingPos; currentDay <= days; i++) { + if (i % 7 === 0 && currentDay !== 1) { + tableRow = quickElement('tr', tableBody); + } + if ((currentDay === todayDay) && (month === todayMonth) && (year === todayYear)) { + todayClass = 'today'; + } else { + todayClass = ''; + } + + // use UTC function; see above for explanation. + if (isSelectedMonth && currentDay === selected.getUTCDate()) { + if (todayClass !== '') { + todayClass += " "; + } + todayClass += "selected"; + } + + const cell = quickElement('td', tableRow, '', 'class', todayClass); + const link = quickElement('a', cell, currentDay, 'href', '#'); + link.addEventListener('click', calendarMonth(year, month)); + currentDay++; + } + + // Draw blanks after end of month (optional, but makes for valid code) + while (tableRow.childNodes.length < 7) { + nonDayCell = quickElement('td', tableRow, ' '); + nonDayCell.className = "nonday"; + } + + calDiv.appendChild(calTable); + } + }; + + // Calendar -- A calendar instance + function Calendar(div_id, callback, selected) { + // div_id (string) is the ID of the element in which the calendar will + // be displayed + // callback (string) is the name of a JavaScript function that will be + // called with the parameters (year, month, day) when a day in the + // calendar is clicked + this.div_id = div_id; + this.callback = callback; + this.today = new Date(); + this.currentMonth = this.today.getMonth() + 1; + this.currentYear = this.today.getFullYear(); + if (typeof selected !== 'undefined') { + this.selected = selected; + } + } + Calendar.prototype = { + drawCurrent: function() { + CalendarNamespace.draw(this.currentMonth, this.currentYear, this.div_id, this.callback, this.selected); + }, + drawDate: function(month, year, selected) { + this.currentMonth = month; + this.currentYear = year; + + if(selected) { + this.selected = selected; + } + + this.drawCurrent(); + }, + drawPreviousMonth: function() { + if (this.currentMonth === 1) { + this.currentMonth = 12; + this.currentYear--; + } + else { + this.currentMonth--; + } + this.drawCurrent(); + }, + drawNextMonth: function() { + if (this.currentMonth === 12) { + this.currentMonth = 1; + this.currentYear++; + } + else { + this.currentMonth++; + } + this.drawCurrent(); + }, + drawPreviousYear: function() { + this.currentYear--; + this.drawCurrent(); + }, + drawNextYear: function() { + this.currentYear++; + this.drawCurrent(); + } + }; + window.Calendar = Calendar; + window.CalendarNamespace = CalendarNamespace; +} diff --git a/static/js/cancel.js b/static/js/cancel.js new file mode 100644 index 0000000..3069c6f --- /dev/null +++ b/static/js/cancel.js @@ -0,0 +1,29 @@ +'use strict'; +{ + // Call function fn when the DOM is loaded and ready. If it is already + // loaded, call the function now. + // http://youmightnotneedjquery.com/#ready + function ready(fn) { + if (document.readyState !== 'loading') { + fn(); + } else { + document.addEventListener('DOMContentLoaded', fn); + } + } + + ready(function() { + function handleClick(event) { + event.preventDefault(); + const params = new URLSearchParams(window.location.search); + if (params.has('_popup')) { + window.close(); // Close the popup. + } else { + window.history.back(); // Otherwise, go back. + } + } + + document.querySelectorAll('.cancel-link').forEach(function(el) { + el.addEventListener('click', handleClick); + }); + }); +} diff --git a/static/js/change_form.js b/static/js/change_form.js new file mode 100644 index 0000000..96a4c62 --- /dev/null +++ b/static/js/change_form.js @@ -0,0 +1,16 @@ +'use strict'; +{ + const inputTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']; + const modelName = document.getElementById('django-admin-form-add-constants').dataset.modelName; + if (modelName) { + const form = document.getElementById(modelName + '_form'); + for (const element of form.elements) { + // HTMLElement.offsetParent returns null when the element is not + // rendered. + if (inputTags.includes(element.tagName) && !element.disabled && element.offsetParent) { + element.focus(); + break; + } + } + } +} diff --git a/static/js/collapse.js b/static/js/collapse.js new file mode 100644 index 0000000..c6c7b0f --- /dev/null +++ b/static/js/collapse.js @@ -0,0 +1,43 @@ +/*global gettext*/ +'use strict'; +{ + window.addEventListener('load', function() { + // Add anchor tag for Show/Hide link + const fieldsets = document.querySelectorAll('fieldset.collapse'); + for (const [i, elem] of fieldsets.entries()) { + // Don't hide if fields in this fieldset have errors + if (elem.querySelectorAll('div.errors, ul.errorlist').length === 0) { + elem.classList.add('collapsed'); + const h2 = elem.querySelector('h2'); + const link = document.createElement('a'); + link.id = 'fieldsetcollapser' + i; + link.className = 'collapse-toggle'; + link.href = '#'; + link.textContent = gettext('Show'); + h2.appendChild(document.createTextNode(' (')); + h2.appendChild(link); + h2.appendChild(document.createTextNode(')')); + } + } + // Add toggle to hide/show anchor tag + const toggleFunc = function(ev) { + if (ev.target.matches('.collapse-toggle')) { + ev.preventDefault(); + ev.stopPropagation(); + const fieldset = ev.target.closest('fieldset'); + if (fieldset.classList.contains('collapsed')) { + // Show + ev.target.textContent = gettext('Hide'); + fieldset.classList.remove('collapsed'); + } else { + // Hide + ev.target.textContent = gettext('Show'); + fieldset.classList.add('collapsed'); + } + } + }; + document.querySelectorAll('fieldset.module').forEach(function(el) { + el.addEventListener('click', toggleFunc); + }); + }); +} diff --git a/static/js/core.js b/static/js/core.js new file mode 100644 index 0000000..0344a13 --- /dev/null +++ b/static/js/core.js @@ -0,0 +1,170 @@ +// Core JavaScript helper functions +'use strict'; + +// quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); +function quickElement() { + const obj = document.createElement(arguments[0]); + if (arguments[2]) { + const textNode = document.createTextNode(arguments[2]); + obj.appendChild(textNode); + } + const len = arguments.length; + for (let i = 3; i < len; i += 2) { + obj.setAttribute(arguments[i], arguments[i + 1]); + } + arguments[1].appendChild(obj); + return obj; +} + +// "a" is reference to an object +function removeChildren(a) { + while (a.hasChildNodes()) { + a.removeChild(a.lastChild); + } +} + +// ---------------------------------------------------------------------------- +// Find-position functions by PPK +// See https://www.quirksmode.org/js/findpos.html +// ---------------------------------------------------------------------------- +function findPosX(obj) { + let curleft = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curleft += obj.offsetLeft - obj.scrollLeft; + obj = obj.offsetParent; + } + } else if (obj.x) { + curleft += obj.x; + } + return curleft; +} + +function findPosY(obj) { + let curtop = 0; + if (obj.offsetParent) { + while (obj.offsetParent) { + curtop += obj.offsetTop - obj.scrollTop; + obj = obj.offsetParent; + } + } else if (obj.y) { + curtop += obj.y; + } + return curtop; +} + +//----------------------------------------------------------------------------- +// Date object extensions +// ---------------------------------------------------------------------------- +{ + Date.prototype.getTwelveHours = function() { + return this.getHours() % 12 || 12; + }; + + Date.prototype.getTwoDigitMonth = function() { + return (this.getMonth() < 9) ? '0' + (this.getMonth() + 1) : (this.getMonth() + 1); + }; + + Date.prototype.getTwoDigitDate = function() { + return (this.getDate() < 10) ? '0' + this.getDate() : this.getDate(); + }; + + Date.prototype.getTwoDigitTwelveHour = function() { + return (this.getTwelveHours() < 10) ? '0' + this.getTwelveHours() : this.getTwelveHours(); + }; + + Date.prototype.getTwoDigitHour = function() { + return (this.getHours() < 10) ? '0' + this.getHours() : this.getHours(); + }; + + Date.prototype.getTwoDigitMinute = function() { + return (this.getMinutes() < 10) ? '0' + this.getMinutes() : this.getMinutes(); + }; + + Date.prototype.getTwoDigitSecond = function() { + return (this.getSeconds() < 10) ? '0' + this.getSeconds() : this.getSeconds(); + }; + + Date.prototype.getAbbrevMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYearAbbrev[this.getMonth()]; + }; + + Date.prototype.getFullMonthName = function() { + return typeof window.CalendarNamespace === "undefined" + ? this.getTwoDigitMonth() + : window.CalendarNamespace.monthsOfYear[this.getMonth()]; + }; + + Date.prototype.strftime = function(format) { + const fields = { + b: this.getAbbrevMonthName(), + B: this.getFullMonthName(), + c: this.toString(), + d: this.getTwoDigitDate(), + H: this.getTwoDigitHour(), + I: this.getTwoDigitTwelveHour(), + m: this.getTwoDigitMonth(), + M: this.getTwoDigitMinute(), + p: (this.getHours() >= 12) ? 'PM' : 'AM', + S: this.getTwoDigitSecond(), + w: '0' + this.getDay(), + x: this.toLocaleDateString(), + X: this.toLocaleTimeString(), + y: ('' + this.getFullYear()).substr(2, 4), + Y: '' + this.getFullYear(), + '%': '%' + }; + let result = '', i = 0; + while (i < format.length) { + if (format.charAt(i) === '%') { + result += fields[format.charAt(i + 1)]; + ++i; + } + else { + result += format.charAt(i); + } + ++i; + } + return result; + }; + + // ---------------------------------------------------------------------------- + // String object extensions + // ---------------------------------------------------------------------------- + String.prototype.strptime = function(format) { + const split_format = format.split(/[.\-/]/); + const date = this.split(/[.\-/]/); + let i = 0; + let day, month, year; + while (i < split_format.length) { + switch (split_format[i]) { + case "%d": + day = date[i]; + break; + case "%m": + month = date[i] - 1; + break; + case "%Y": + year = date[i]; + break; + case "%y": + // A %y value in the range of [00, 68] is in the current + // century, while [69, 99] is in the previous century, + // according to the Open Group Specification. + if (parseInt(date[i], 10) >= 69) { + year = date[i]; + } else { + year = (new Date(Date.UTC(date[i], 0))).getUTCFullYear() + 100; + } + break; + } + ++i; + } + // Create Date object from UTC since the parsed value is supposed to be + // in UTC, not local time. Also, the calendar uses UTC functions for + // date extraction. + return new Date(Date.UTC(year, month, day)); + }; +} diff --git a/static/js/filters.js b/static/js/filters.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/static/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/static/js/games/bac-play.js b/static/js/games/bac-play.js new file mode 100644 index 0000000..bb4b8a7 --- /dev/null +++ b/static/js/games/bac-play.js @@ -0,0 +1,161 @@ +document.addEventListener('DOMContentLoaded', () => { + const container = document.querySelector('.container'); + const gameId = container.dataset.gameId; + const players = document.querySelector('#playersList'); + const countdownDisplay = document.querySelector('#countdownDisplay'); + const buttonFinish = document.querySelector('#buttonFinish'); + const playerId = buttonFinish.dataset.playerId; + const roundId = container.dataset.roundId; + + let countdownInterval = null; + + /* Fonction pour récupérer les informations de la partie */ + const fetchGameInfo = async () => { + try { + const response = await fetch(`/games/api/bac/${gameId}/info_party`); + const data = await response.json(); + + // Mise à jour de la liste des joueurs + players.innerHTML = ""; + Object.values(data.players).forEach(player => { + const listItem = document.createElement("li"); + const status = player.status === "playing" ? 'En train de jouer...' : 'A fini de jouer'; + listItem.innerHTML = `${player.username} (${status})`; + players.appendChild(listItem); + }); + + const allPlayersOvered = data.players.every(player => player.status === "overed"); + + // Gestion du décompte + if (data.countdown_started && data.current_phase === "finish_game") { + if (!countdownInterval && data.countdown_time > 0) { + startCountdown(data.countdown_time); + countdownDisplay.style.display = "block"; // Affiche le décompte + } else if (data.countdown_time <= 0 || allPlayersOvered) { + clearInterval(countdownInterval); // Arrête l'intervalle si actif + countdownDisplay.style.display = "none"; // Cache le décompte + stopGame(); + } + } + + // On vérifie que le joueur est bien la liste des joueurs et que son status est overed + console.log(data.players) + console.log(playerId) + const player = data.players.find(player => player.username === playerId); + console.log(player) + if(player && player.status === "overed") { + clearInterval(countdownInterval); // Arrête l'intervalle si actif + countdownDisplay.style.display = "none"; // Cache le décompte + buttonFinish.style.display = "none"; // Cache le bouton de fin de partie + document.querySelector('#textFinished').style.display = "block"; // Affiche le message de fin de partie + // window.location.href = `/games/bac/party/${gameId}/results`; + } else { + console.log('Le joueur n\'a pas terminé la partie'); + } + } catch (error) { + console.error("Erreur lors de la récupération des informations de la partie :", error); + } + }; + + /* Fonction pour démarrer le décompte */ + const startCountdown = (initialTime) => { + console.log("Démarrage du décompte..."); + let countdownTime = initialTime; + + countdownInterval = setInterval(() => { + // Affichage du décompte + console.log(`Temps restant : ${countdownTime} seconde${countdownTime > 1 ? 's' : ''}`); + countdownTime--; + countdownDisplay.style.display = "block"; + countdownDisplay.textContent = `Temps restant : ${countdownTime} seconde${countdownTime > 1 ? 's' : ''}`; + + if (countdownTime <= 0) { + clearInterval(countdownInterval); + countdownDisplay.style.display = "none"; // Cache le décompte + buttonFinish.style.display = "none"; // Cache le bouton + stopGame(); + } + }, 1000); + }; + + /* Fonction pour gérer la soumission du formulaire */ + const handleFormSubmit = async (form) => { + try { + const formData = new FormData(form); + const response = await fetch(form.action, { + method: "POST", + body: formData, + }); + + if (response.ok) { + console.log("Formulaire soumis avec succès."); + buttonFinish.style.display = "none"; // Cache le bouton + document.querySelector('#textFinished').style.display = "block"; // Affiche le message + + // Lance le décompte pour tous les joueurs + const countdownResponse = await startCountdownForAllPlayers(); + if (countdownResponse && countdownResponse.success) { + await fetchGameInfo(); // Rafraîchit les informations + } + } else { + console.error("Erreur lors de la soumission du formulaire :", response.status); + } + } catch (error) { + console.error("Erreur réseau lors de la soumission du formulaire :", error); + } + }; + + /* Fonction pour informer le serveur de démarrer le décompte */ + const startCountdownForAllPlayers = async () => { + console.log('On appelle l\'API pour lancer le décompte'); + try { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; + const response = await fetch(`/games/api/${gameId}/start_countdown?type=finish_game`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify({}) + }); + + const data = await response.json(); + if (!response.ok) { + console.error("Erreur lors du démarrage du décompte :", response.status); + console.error("Détails de l'erreur:", data); + } + return data; + } catch (error) { + console.error("Erreur réseau lors du démarrage du décompte :", error); + return null; + } + }; + + /* Fonction pour gérer la fin de la partie */ + const stopGame = async () => { + try { + const response = await fetch(`/games/api/bac/${gameId}/end_game`); + + if (response.ok) { + console.log('Partie terminée.'); + window.location.href = `/games/bac/party/${gameId}/results`; + } else { + console.error("Erreur lors de la fin de la partie :", response.status); + } + } catch (error) { + console.error("Erreur réseau lors de la fin de la partie :", error); + } + }; + + /* Initialisation */ + const form = document.querySelector('form'); + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); // Empêche le rechargement de la page + handleFormSubmit(form); + }); + } + + fetchGameInfo(); + setInterval(fetchGameInfo, 3000); // Vérifie les informations toutes les 3 secondes +}); diff --git a/static/js/games/bac-results.js b/static/js/games/bac-results.js new file mode 100644 index 0000000..eecdc29 --- /dev/null +++ b/static/js/games/bac-results.js @@ -0,0 +1,24 @@ +document.addEventListener('DOMContentLoaded', () => { + const players = document.querySelector('#playersList'); + const gameId = document.querySelector(".container").dataset.gameId; + + const fetchPlayersList = async () => { + try { + const response = await fetch(`/games/api/bac/${gameId}/players`); + const data = await response.json(); + + // Met à jour la liste des joueurs + players.innerHTML = ""; + data.players.forEach(player => { + const listItem = document.createElement("li"); + const status = player.status === "playing" ? 'En train de jouer...' : 'A fini de jouer'; + listItem.textContent = `${player.username} (${status})`; + players.appendChild(listItem); + }); + } catch (error) { + console.error("Erreur lors de la récupération des joueurs :", error); + } + }; + + fetchPlayersList(); +}); \ No newline at end of file diff --git a/static/js/games/bac-start.js b/static/js/games/bac-start.js new file mode 100644 index 0000000..defe7dc --- /dev/null +++ b/static/js/games/bac-start.js @@ -0,0 +1,112 @@ +document.addEventListener('DOMContentLoaded', () => { + const container = document.querySelector('.container'); + const gameId = document.querySelector(".container").dataset.gameId; + const playerId = document.querySelector('#playButton').dataset.playerId; + const players = document.querySelector('#playersList'); + const playButton = document.querySelector('#playButton'); + + const infoNbPlayersReady = document.querySelector('#infoNbPlayersReady'); + const readyCount = document.querySelector('#readyCount'); + + let countdownStarted = false; + let countdownInterval; + let countdownIntervalStarted = false; + + /* Fonction pour mettre à jour la liste des joueurs */ + const fetchPlayersList = async () => { + try { + const response = await fetch(`/games/api/bac/${gameId}/players`); + const data = await response.json(); + + // Met à jour la liste des joueurs + players.innerHTML = ""; + data.players.forEach(player => { + const listItem = document.createElement("li"); + listItem.textContent = `${player.username}`; + players.appendChild(listItem); + }); + + // Met à jour le nombre de joueurs prêts + const gameInfo = await fetch(`/games/api/bac/${gameId}/info`); + const infoData = await gameInfo.json(); + readyCount.textContent = infoData.all_ready; + + // Affiche ou masque les messages en fonction du nombre de joueurs + playButton.style.display = data.players.length > 1 ? 'block' : 'none'; + document.querySelector('#infoNbPlayersReady').style.display = data.players.length > 1 ? 'block' : 'none'; + document.querySelector('#waitingPlayers').style.display = data.players.length > 1 ? 'none' : 'block'; + } catch (error) { + console.error("Erreur lors de la récupération des joueurs :", error); + } + }; + + /* Fonction pour vérifier le décompte */ + const checkCountdownStatus = async () => { + try { + const response = await fetch(`/games/api/${gameId}/countdown_status`); + const data = await response.json(); + + console.log("Countdown status data:", data); // Vérification + + if (data.countdown_started && data.countdown_time > 0) { + const countdownElement = document.querySelector('#countdown') || document.createElement('p'); + countdownElement.id = 'countdown'; + countdownElement.className = 'countdown'; + countdownElement.innerHTML = `La partie commence dans ${data.countdown_time} seconde${data.countdown_time > 1 ? 's' : ''}...`; + container.appendChild(countdownElement); + } + + console.log(data) + + if (data.countdown_started === true && data.countdown_time === 0) { + clearInterval(countdownInterval); // Arrête l'intervalle + window.location.href = `/games/bac/party/${gameId}/play`; + } + } catch (error) { + console.error("Erreur lors de la vérification du décompte :", error); + } + }; + + /* Fonction pour gérer le clic sur le bouton "Prêt" */ + const toggleReadyStatus = async () => { + try { + const response = await fetch(`/games/api/bac/${gameId}/player/${playerId}/toggle_ready`); + + if (response.ok) { + const data = await response.json(); + playButton.textContent = data.is_ready ? "Pas prêt :(" : "Prêt :)"; + playButton.className = data.is_ready ? "btn btn-warning btn-large" : "btn btn-default btn-large"; + + // Si tous les joueurs sont prêts et que le décompte n'a pas commencé + if (data.all_ready === players.childElementCount && !countdownStarted) { + countdownStarted = true; // Empêche de redémarrer le décompte + const response = await fetch(`/games/api/${gameId}/start_countdown?type=ready_game`); + const countdownData = await response.json(); + + if (countdownData.countdown_started) { + console.log("Décompte démarré par le serveur."); + startCountdownCheck(); // Commence à vérifier le décompte + } + } + } else { + console.error("ERREUR API :", response.status); + } + } catch (error) { + console.error("Erreur lors du changement d'état :", error); + } + }; + + /* Fonction pour démarrer la vérification du décompte */ + const startCountdownCheck = () => { + if (!countdownIntervalStarted) { + countdownIntervalStarted = true; + countdownInterval = setInterval(checkCountdownStatus, 1000); + } + }; + + /* INITIALISATION */ + playButton.addEventListener('click', toggleReadyStatus); + fetchPlayersList(); + setInterval(fetchPlayersList, 3000); + setInterval(checkCountdownStatus, 1000); +}); \ No newline at end of file diff --git a/static/js/games/bac-utils.js b/static/js/games/bac-utils.js new file mode 100644 index 0000000..d6d4872 --- /dev/null +++ b/static/js/games/bac-utils.js @@ -0,0 +1,18 @@ +document.addEventListener('DOMContentLoaded', () => { + const status = document.querySelector('#status'); + /* Fonction pour vérifier le statut de la partie */ + const updateStatus = () => { + const statuses = { + waiting: { text: 'En attente', style: 'color:orange;font-style:italic' }, + in_progress: { text: 'En cours', style: 'color:green;font-style:italic' }, + finished: { text: 'Terminée', style: 'color:red;font-style:italic' }, + }; + + const currentStatus = statuses[status.textContent]; + if (currentStatus) { + status.style = currentStatus.style; + status.textContent = currentStatus.text; + } + }; + updateStatus(); +}) \ No newline at end of file diff --git a/static/js/games/quiz.js b/static/js/games/quiz.js new file mode 100644 index 0000000..e1c72bf --- /dev/null +++ b/static/js/games/quiz.js @@ -0,0 +1,55 @@ +document.addEventListener('DOMContentLoaded', () => { + const buttonAddResponse = document.querySelector('#add-response') + const buttonAddAsk = document.querySelector('#add-ask') + const divResponses = document.querySelector('#responses') + const divQuestions = document.querySelector('#questions') + let responses = 0; + let questions = 0; + + if (buttonAddResponse) { + buttonAddResponse.addEventListener('click', () => { + // Si on clique sur le bouton, on ajoute un champ de réponse + responses++; + const newResponse = document.createElement('div') + newResponse.classList.add('form-group') + newResponse.innerHTML = ` + + ` + divResponses.appendChild(newResponse) + }) + } + + if (buttonAddAsk) { + buttonAddAsk.addEventListener('click', () => { + // Si on clique sur le bouton, on ajoute un champ de question + questions++; + const newAsk = document.createElement('div') + newAsk.classList.add('form-group') + newAsk.innerHTML = ` + + Ajouter une réponse +
      + ` + divQuestions.appendChild(newAsk) + + const hr = document.createElement('hr'); + hr.style.marginTop = '20px'; + hr.style.marginBottom = '20px'; + divQuestions.appendChild(hr); + + // Add event listener for the new "Ajouter une réponse" button + newAsk.querySelector('.add-response').addEventListener('click', (event) => { + const questionId = event.target.getAttribute('data-question'); + const responseContainer = document.querySelector(`#responses-${questionId}`); + const responseCount = responseContainer.children.length + 1; + const newResponse = document.createElement('div'); + newResponse.classList.add('form-group'); + newResponse.innerHTML = ` + +

      + `; + responseContainer.appendChild(newResponse); + }); + }) + } +}) \ No newline at end of file diff --git a/static/js/inlines.js b/static/js/inlines.js new file mode 100644 index 0000000..e9a1dfe --- /dev/null +++ b/static/js/inlines.js @@ -0,0 +1,359 @@ +/*global DateTimeShortcuts, SelectFilter*/ +/** + * Django admin inlines + * + * Based on jQuery Formset 1.1 + * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) + * @requires jQuery 1.2.6 or later + * + * Copyright (c) 2009, Stanislaus Madueke + * All rights reserved. + * + * Spiced up with Code from Zain Memon's GSoC project 2009 + * and modified for Django by Jannis Leidel, Travis Swicegood and Julien Phalip. + * + * Licensed under the New BSD License + * See: https://opensource.org/licenses/bsd-license.php + */ +'use strict'; +{ + const $ = django.jQuery; + $.fn.formset = function(opts) { + const options = $.extend({}, $.fn.formset.defaults, opts); + const $this = $(this); + const $parent = $this.parent(); + const updateElementIndex = function(el, prefix, ndx) { + const id_regex = new RegExp("(" + prefix + "-(\\d+|__prefix__))"); + const replacement = prefix + "-" + ndx; + if ($(el).prop("for")) { + $(el).prop("for", $(el).prop("for").replace(id_regex, replacement)); + } + if (el.id) { + el.id = el.id.replace(id_regex, replacement); + } + if (el.name) { + el.name = el.name.replace(id_regex, replacement); + } + }; + const totalForms = $("#id_" + options.prefix + "-TOTAL_FORMS").prop("autocomplete", "off"); + let nextIndex = parseInt(totalForms.val(), 10); + const maxForms = $("#id_" + options.prefix + "-MAX_NUM_FORMS").prop("autocomplete", "off"); + const minForms = $("#id_" + options.prefix + "-MIN_NUM_FORMS").prop("autocomplete", "off"); + let addButton; + + /** + * The "Add another MyModel" button below the inline forms. + */ + const addInlineAddButton = function() { + if (addButton === null) { + if ($this.prop("tagName") === "TR") { + // If forms are laid out as table rows, insert the + // "add" button in a new table row: + const numCols = $this.eq(-1).children().length; + $parent.append('' + options.addText + ""); + addButton = $parent.find("tr:last a"); + } else { + // Otherwise, insert it immediately after the last form: + $this.filter(":last").after('"); + addButton = $this.filter(":last").next().find("a"); + } + } + addButton.on('click', addInlineClickHandler); + }; + + const addInlineClickHandler = function(e) { + e.preventDefault(); + const template = $("#" + options.prefix + "-empty"); + const row = template.clone(true); + row.removeClass(options.emptyCssClass) + .addClass(options.formCssClass) + .attr("id", options.prefix + "-" + nextIndex); + addInlineDeleteButton(row); + row.find("*").each(function() { + updateElementIndex(this, options.prefix, totalForms.val()); + }); + // Insert the new form when it has been fully edited. + row.insertBefore($(template)); + // Update number of total forms. + $(totalForms).val(parseInt(totalForms.val(), 10) + 1); + nextIndex += 1; + // Hide the add button if there's a limit and it's been reached. + if ((maxForms.val() !== '') && (maxForms.val() - totalForms.val()) <= 0) { + addButton.parent().hide(); + } + // Show the remove buttons if there are more than min_num. + toggleDeleteButtonVisibility(row.closest('.inline-group')); + + // Pass the new form to the post-add callback, if provided. + if (options.added) { + options.added(row); + } + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); + }; + + /** + * The "X" button that is part of every unsaved inline. + * (When saved, it is replaced with a "Delete" checkbox.) + */ + const addInlineDeleteButton = function(row) { + if (row.is("tr")) { + // If the forms are laid out in table rows, insert + // the remove button into the last table cell: + row.children(":last").append('"); + } else if (row.is("ul") || row.is("ol")) { + // If they're laid out as an ordered/unordered list, + // insert an
    • after the last list item: + row.append('
    • ' + options.deleteText + "
    • "); + } else { + // Otherwise, just insert the remove button as the + // last child element of the form's container: + row.children(":first").append('' + options.deleteText + ""); + } + // Add delete handler for each row. + row.find("a." + options.deleteCssClass).on('click', inlineDeleteHandler.bind(this)); + }; + + const inlineDeleteHandler = function(e1) { + e1.preventDefault(); + const deleteButton = $(e1.target); + const row = deleteButton.closest('.' + options.formCssClass); + const inlineGroup = row.closest('.inline-group'); + // Remove the parent form containing this button, + // and also remove the relevant row with non-field errors: + const prevRow = row.prev(); + if (prevRow.length && prevRow.hasClass('row-form-errors')) { + prevRow.remove(); + } + row.remove(); + nextIndex -= 1; + // Pass the deleted form to the post-delete callback, if provided. + if (options.removed) { + options.removed(row); + } + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); + // Update the TOTAL_FORMS form count. + const forms = $("." + options.formCssClass); + $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); + // Show add button again once below maximum number. + if ((maxForms.val() === '') || (maxForms.val() - forms.length) > 0) { + addButton.parent().show(); + } + // Hide the remove buttons if at min_num. + toggleDeleteButtonVisibility(inlineGroup); + // Also, update names and ids for all remaining form controls so + // they remain in sequence: + let i, formCount; + const updateElementCallback = function() { + updateElementIndex(this, options.prefix, i); + }; + for (i = 0, formCount = forms.length; i < formCount; i++) { + updateElementIndex($(forms).get(i), options.prefix, i); + $(forms.get(i)).find("*").each(updateElementCallback); + } + }; + + const toggleDeleteButtonVisibility = function(inlineGroup) { + if ((minForms.val() !== '') && (minForms.val() - totalForms.val()) >= 0) { + inlineGroup.find('.inline-deletelink').hide(); + } else { + inlineGroup.find('.inline-deletelink').show(); + } + }; + + $this.each(function(i) { + $(this).not("." + options.emptyCssClass).addClass(options.formCssClass); + }); + + // Create the delete buttons for all unsaved inlines: + $this.filter('.' + options.formCssClass + ':not(.has_original):not(.' + options.emptyCssClass + ')').each(function() { + addInlineDeleteButton($(this)); + }); + toggleDeleteButtonVisibility($this); + + // Create the add button, initially hidden. + addButton = options.addButton; + addInlineAddButton(); + + // Show the add button if allowed to add more items. + // Note that max_num = None translates to a blank string. + const showAddButton = maxForms.val() === '' || (maxForms.val() - totalForms.val()) > 0; + if ($this.length && showAddButton) { + addButton.parent().show(); + } else { + addButton.parent().hide(); + } + + return this; + }; + + /* Setup plugin defaults */ + $.fn.formset.defaults = { + prefix: "form", // The form prefix for your django formset + addText: "add another", // Text for the add link + deleteText: "remove", // Text for the delete link + addCssClass: "add-row", // CSS class applied to the add link + deleteCssClass: "delete-row", // CSS class applied to the delete link + emptyCssClass: "empty-row", // CSS class applied to the empty row + formCssClass: "dynamic-form", // CSS class applied to each form in a formset + added: null, // Function called each time a new form is added + removed: null, // Function called each time a form is deleted + addButton: null // Existing add button to use + }; + + + // Tabular inlines --------------------------------------------------------- + $.fn.tabularFormset = function(selector, options) { + const $rows = $(this); + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets are a part of the new form, + // instantiate a new SelectFilter instance for it. + if (typeof SelectFilter !== 'undefined') { + $('.selectfilter').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $('.selectfilterstacked').each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + dependencies.push('#' + row.find('.field-' + field_name).find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + }, + addButton: options.addButton + }); + + return $rows; + }; + + // Stacked inlines --------------------------------------------------------- + $.fn.stackedFormset = function(selector, options) { + const $rows = $(this); + const updateInlineLabel = function(row) { + $(selector).find(".inline_label").each(function(i) { + const count = i + 1; + $(this).html($(this).html().replace(/(#\d+)/g, "#" + count)); + }); + }; + + const reinitDateTimeShortCuts = function() { + // Reinitialize the calendar and clock widgets by force, yuck. + if (typeof DateTimeShortcuts !== "undefined") { + $(".datetimeshortcuts").remove(); + DateTimeShortcuts.init(); + } + }; + + const updateSelectFilter = function() { + // If any SelectFilter widgets were added, instantiate a new instance. + if (typeof SelectFilter !== "undefined") { + $(".selectfilter").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, false); + }); + $(".selectfilterstacked").each(function(index, value) { + SelectFilter.init(value.id, this.dataset.fieldName, true); + }); + } + }; + + const initPrepopulatedFields = function(row) { + row.find('.prepopulated_field').each(function() { + const field = $(this), + input = field.find('input, select, textarea'), + dependency_list = input.data('dependency_list') || [], + dependencies = []; + $.each(dependency_list, function(i, field_name) { + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); + }); + if (dependencies.length) { + input.prepopulate(dependencies, input.attr('maxlength')); + } + }); + }; + + $rows.formset({ + prefix: options.prefix, + addText: options.addText, + formCssClass: "dynamic-" + options.prefix, + deleteCssClass: "inline-deletelink", + deleteText: options.deleteText, + emptyCssClass: "empty-form", + removed: updateInlineLabel, + added: function(row) { + initPrepopulatedFields(row); + reinitDateTimeShortCuts(); + updateSelectFilter(); + updateInlineLabel(row); + }, + addButton: options.addButton + }); + + return $rows; + }; + + $(document).ready(function() { + $(".js-inline-admin-formset").each(function() { + const data = $(this).data(), + inlineOptions = data.inlineFormset; + let selector; + switch(data.inlineType) { + case "stacked": + selector = inlineOptions.name + "-group .inline-related"; + $(selector).stackedFormset(selector, inlineOptions.options); + break; + case "tabular": + selector = inlineOptions.name + "-group .tabular.inline-related tbody:first > tr.form-row"; + $(selector).tabularFormset(selector, inlineOptions.options); + break; + } + }); + }); +} diff --git a/static/js/jquery.init.js b/static/js/jquery.init.js new file mode 100644 index 0000000..f40b27f --- /dev/null +++ b/static/js/jquery.init.js @@ -0,0 +1,8 @@ +/*global jQuery:false*/ +'use strict'; +/* Puts the included jQuery into our own namespace using noConflict and passing + * it 'true'. This ensures that the included jQuery doesn't pollute the global + * namespace (i.e. this preserves pre-existing values for both window.$ and + * window.jQuery). + */ +window.django = {jQuery: jQuery.noConflict(true)}; diff --git a/static/js/nav_sidebar.js b/static/js/nav_sidebar.js new file mode 100644 index 0000000..7e735db --- /dev/null +++ b/static/js/nav_sidebar.js @@ -0,0 +1,79 @@ +'use strict'; +{ + const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); + if (toggleNavSidebar !== null) { + const navSidebar = document.getElementById('nav-sidebar'); + const main = document.getElementById('main'); + let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); + if (navSidebarIsOpen === null) { + navSidebarIsOpen = 'true'; + } + main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + + toggleNavSidebar.addEventListener('click', function() { + if (navSidebarIsOpen === 'true') { + navSidebarIsOpen = 'false'; + } else { + navSidebarIsOpen = 'true'; + } + localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); + main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); + }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } + } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); +} diff --git a/static/js/popup_response.js b/static/js/popup_response.js new file mode 100644 index 0000000..2b1d3dd --- /dev/null +++ b/static/js/popup_response.js @@ -0,0 +1,16 @@ +/*global opener */ +'use strict'; +{ + const initData = JSON.parse(document.getElementById('django-admin-popup-response-constants').dataset.popupResponse); + switch(initData.action) { + case 'change': + opener.dismissChangeRelatedObjectPopup(window, initData.value, initData.obj, initData.new_value); + break; + case 'delete': + opener.dismissDeleteRelatedObjectPopup(window, initData.value); + break; + default: + opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj); + break; + } +} diff --git a/static/js/prepopulate.js b/static/js/prepopulate.js new file mode 100644 index 0000000..89e95ab --- /dev/null +++ b/static/js/prepopulate.js @@ -0,0 +1,43 @@ +/*global URLify*/ +'use strict'; +{ + const $ = django.jQuery; + $.fn.prepopulate = function(dependencies, maxLength, allowUnicode) { + /* + Depends on urlify.js + Populates a selected field with the values of the dependent fields, + URLifies and shortens the string. + dependencies - array of dependent fields ids + maxLength - maximum length of the URLify'd string + allowUnicode - Unicode support of the URLify'd string + */ + return this.each(function() { + const prepopulatedField = $(this); + + const populate = function() { + // Bail if the field's value has been changed by the user + if (prepopulatedField.data('_changed')) { + return; + } + + const values = []; + $.each(dependencies, function(i, field) { + field = $(field); + if (field.val().length > 0) { + values.push(field.val()); + } + }); + prepopulatedField.val(URLify(values.join(' '), maxLength, allowUnicode)); + }; + + prepopulatedField.data('_changed', false); + prepopulatedField.on('change', function() { + prepopulatedField.data('_changed', true); + }); + + if (!prepopulatedField.val()) { + $(dependencies.join(',')).on('keyup change focus', populate); + } + }); + }; +} diff --git a/static/js/prepopulate_init.js b/static/js/prepopulate_init.js new file mode 100644 index 0000000..a58841f --- /dev/null +++ b/static/js/prepopulate_init.js @@ -0,0 +1,15 @@ +'use strict'; +{ + const $ = django.jQuery; + const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); + $.each(fields, function(index, field) { + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); + $(field.id).data('dependency_list', field.dependency_list).prepopulate( + field.dependency_ids, field.maxLength, field.allowUnicode + ); + }); +} diff --git a/static/js/theme.js b/static/js/theme.js new file mode 100644 index 0000000..794cd15 --- /dev/null +++ b/static/js/theme.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/static/js/urlify.js b/static/js/urlify.js new file mode 100644 index 0000000..9fc0409 --- /dev/null +++ b/static/js/urlify.js @@ -0,0 +1,169 @@ +/*global XRegExp*/ +'use strict'; +{ + const LATIN_MAP = { + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'Ç': 'C', 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', 'Ì': 'I', 'Í': 'I', + 'Î': 'I', 'Ï': 'I', 'Ð': 'D', 'Ñ': 'N', 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', + 'Õ': 'O', 'Ö': 'O', 'Ő': 'O', 'Ø': 'O', 'Ù': 'U', 'Ú': 'U', 'Û': 'U', + 'Ü': 'U', 'Ű': 'U', 'Ý': 'Y', 'Þ': 'TH', 'Ÿ': 'Y', 'ß': 'ss', 'à': 'a', + 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', 'ç': 'c', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', 'ì': 'i', 'í': 'i', 'î': 'i', + 'ï': 'i', 'ð': 'd', 'ñ': 'n', 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', + 'ö': 'o', 'ő': 'o', 'ø': 'o', 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ű': 'u', 'ý': 'y', 'þ': 'th', 'ÿ': 'y' + }; + const LATIN_SYMBOLS_MAP = { + '©': '(c)' + }; + const GREEK_MAP = { + 'α': 'a', 'β': 'b', 'γ': 'g', 'δ': 'd', 'ε': 'e', 'ζ': 'z', 'η': 'h', + 'θ': '8', 'ι': 'i', 'κ': 'k', 'λ': 'l', 'μ': 'm', 'ν': 'n', 'ξ': '3', + 'ο': 'o', 'π': 'p', 'ρ': 'r', 'σ': 's', 'τ': 't', 'υ': 'y', 'φ': 'f', + 'χ': 'x', 'ψ': 'ps', 'ω': 'w', 'ά': 'a', 'έ': 'e', 'ί': 'i', 'ό': 'o', + 'ύ': 'y', 'ή': 'h', 'ώ': 'w', 'ς': 's', 'ϊ': 'i', 'ΰ': 'y', 'ϋ': 'y', + 'ΐ': 'i', 'Α': 'A', 'Β': 'B', 'Γ': 'G', 'Δ': 'D', 'Ε': 'E', 'Ζ': 'Z', + 'Η': 'H', 'Θ': '8', 'Ι': 'I', 'Κ': 'K', 'Λ': 'L', 'Μ': 'M', 'Ν': 'N', + 'Ξ': '3', 'Ο': 'O', 'Π': 'P', 'Ρ': 'R', 'Σ': 'S', 'Τ': 'T', 'Υ': 'Y', + 'Φ': 'F', 'Χ': 'X', 'Ψ': 'PS', 'Ω': 'W', 'Ά': 'A', 'Έ': 'E', 'Ί': 'I', + 'Ό': 'O', 'Ύ': 'Y', 'Ή': 'H', 'Ώ': 'W', 'Ϊ': 'I', 'Ϋ': 'Y' + }; + const TURKISH_MAP = { + 'ş': 's', 'Ş': 'S', 'ı': 'i', 'İ': 'I', 'ç': 'c', 'Ç': 'C', 'ü': 'u', + 'Ü': 'U', 'ö': 'o', 'Ö': 'O', 'ğ': 'g', 'Ğ': 'G' + }; + const ROMANIAN_MAP = { + 'ă': 'a', 'î': 'i', 'ș': 's', 'ț': 't', 'â': 'a', + 'Ă': 'A', 'Î': 'I', 'Ș': 'S', 'Ț': 'T', 'Â': 'A' + }; + const RUSSIAN_MAP = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'c', 'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', + 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'C', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', + 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + }; + const UKRAINIAN_MAP = { + 'Є': 'Ye', 'І': 'I', 'Ї': 'Yi', 'Ґ': 'G', 'є': 'ye', 'і': 'i', + 'ї': 'yi', 'ґ': 'g' + }; + const CZECH_MAP = { + 'č': 'c', 'ď': 'd', 'ě': 'e', 'ň': 'n', 'ř': 'r', 'š': 's', 'ť': 't', + 'ů': 'u', 'ž': 'z', 'Č': 'C', 'Ď': 'D', 'Ě': 'E', 'Ň': 'N', 'Ř': 'R', + 'Š': 'S', 'Ť': 'T', 'Ů': 'U', 'Ž': 'Z' + }; + const SLOVAK_MAP = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'í': 'i', 'ľ': 'l', + 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ŕ': 'r', 'š': 's', 'ť': 't', + 'ú': 'u', 'ý': 'y', 'ž': 'z', + 'Á': 'a', 'Ä': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Í': 'I', 'Ľ': 'L', + 'Ĺ': 'L', 'Ň': 'N', 'Ó': 'O', 'Ô': 'O', 'Ŕ': 'R', 'Š': 'S', 'Ť': 'T', + 'Ú': 'U', 'Ý': 'Y', 'Ž': 'Z' + }; + const POLISH_MAP = { + 'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', + 'ź': 'z', 'ż': 'z', + 'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', + 'Ź': 'Z', 'Ż': 'Z' + }; + const LATVIAN_MAP = { + 'ā': 'a', 'č': 'c', 'ē': 'e', 'ģ': 'g', 'ī': 'i', 'ķ': 'k', 'ļ': 'l', + 'ņ': 'n', 'š': 's', 'ū': 'u', 'ž': 'z', + 'Ā': 'A', 'Č': 'C', 'Ē': 'E', 'Ģ': 'G', 'Ī': 'I', 'Ķ': 'K', 'Ļ': 'L', + 'Ņ': 'N', 'Š': 'S', 'Ū': 'U', 'Ž': 'Z' + }; + const ARABIC_MAP = { + 'أ': 'a', 'ب': 'b', 'ت': 't', 'ث': 'th', 'ج': 'g', 'ح': 'h', 'خ': 'kh', 'د': 'd', + 'ذ': 'th', 'ر': 'r', 'ز': 'z', 'س': 's', 'ش': 'sh', 'ص': 's', 'ض': 'd', 'ط': 't', + 'ظ': 'th', 'ع': 'aa', 'غ': 'gh', 'ف': 'f', 'ق': 'k', 'ك': 'k', 'ل': 'l', 'م': 'm', + 'ن': 'n', 'ه': 'h', 'و': 'o', 'ي': 'y' + }; + const LITHUANIAN_MAP = { + 'ą': 'a', 'č': 'c', 'ę': 'e', 'ė': 'e', 'į': 'i', 'š': 's', 'ų': 'u', + 'ū': 'u', 'ž': 'z', + 'Ą': 'A', 'Č': 'C', 'Ę': 'E', 'Ė': 'E', 'Į': 'I', 'Š': 'S', 'Ų': 'U', + 'Ū': 'U', 'Ž': 'Z' + }; + const SERBIAN_MAP = { + 'ђ': 'dj', 'ј': 'j', 'љ': 'lj', 'њ': 'nj', 'ћ': 'c', 'џ': 'dz', + 'đ': 'dj', 'Ђ': 'Dj', 'Ј': 'j', 'Љ': 'Lj', 'Њ': 'Nj', 'Ћ': 'C', + 'Џ': 'Dz', 'Đ': 'Dj' + }; + const AZERBAIJANI_MAP = { + 'ç': 'c', 'ə': 'e', 'ğ': 'g', 'ı': 'i', 'ö': 'o', 'ş': 's', 'ü': 'u', + 'Ç': 'C', 'Ə': 'E', 'Ğ': 'G', 'İ': 'I', 'Ö': 'O', 'Ş': 'S', 'Ü': 'U' + }; + const GEORGIAN_MAP = { + 'ა': 'a', 'ბ': 'b', 'გ': 'g', 'დ': 'd', 'ე': 'e', 'ვ': 'v', 'ზ': 'z', + 'თ': 't', 'ი': 'i', 'კ': 'k', 'ლ': 'l', 'მ': 'm', 'ნ': 'n', 'ო': 'o', + 'პ': 'p', 'ჟ': 'j', 'რ': 'r', 'ს': 's', 'ტ': 't', 'უ': 'u', 'ფ': 'f', + 'ქ': 'q', 'ღ': 'g', 'ყ': 'y', 'შ': 'sh', 'ჩ': 'ch', 'ც': 'c', 'ძ': 'dz', + 'წ': 'w', 'ჭ': 'ch', 'ხ': 'x', 'ჯ': 'j', 'ჰ': 'h' + }; + + const ALL_DOWNCODE_MAPS = [ + LATIN_MAP, + LATIN_SYMBOLS_MAP, + GREEK_MAP, + TURKISH_MAP, + ROMANIAN_MAP, + RUSSIAN_MAP, + UKRAINIAN_MAP, + CZECH_MAP, + SLOVAK_MAP, + POLISH_MAP, + LATVIAN_MAP, + ARABIC_MAP, + LITHUANIAN_MAP, + SERBIAN_MAP, + AZERBAIJANI_MAP, + GEORGIAN_MAP + ]; + + const Downcoder = { + 'Initialize': function() { + if (Downcoder.map) { // already made + return; + } + Downcoder.map = {}; + for (const lookup of ALL_DOWNCODE_MAPS) { + Object.assign(Downcoder.map, lookup); + } + Downcoder.regex = new RegExp(Object.keys(Downcoder.map).join('|'), 'g'); + } + }; + + function downcode(slug) { + Downcoder.Initialize(); + return slug.replace(Downcoder.regex, function(m) { + return Downcoder.map[m]; + }); + } + + + function URLify(s, num_chars, allowUnicode) { + // changes, e.g., "Petty theft" to "petty-theft" + if (!allowUnicode) { + s = downcode(s); + } + s = s.toLowerCase(); // convert to lowercase + // if downcode doesn't hit, the char will be stripped here + if (allowUnicode) { + // Keep Unicode letters including both lowercase and uppercase + // characters, whitespace, and dash; remove other characters. + s = XRegExp.replace(s, XRegExp('[^-_\\p{L}\\p{N}\\s]', 'g'), ''); + } else { + s = s.replace(/[^-\w\s]/g, ''); // remove unneeded chars + } + s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces + s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens + s = s.substring(0, num_chars); // trim to first num_chars chars + return s.replace(/-+$/g, ''); // trim any trailing hyphens + } + window.URLify = URLify; +} diff --git a/static/js/utils/bbcode-bar.html b/static/js/utils/bbcode-bar.html new file mode 100644 index 0000000..d8d128b --- /dev/null +++ b/static/js/utils/bbcode-bar.html @@ -0,0 +1,32 @@ +
      +
        +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      • +
      +
      +
      +
      + Ma Galerie + +
      + +
      \ No newline at end of file diff --git a/static/js/utils/bbcode_display.js b/static/js/utils/bbcode_display.js new file mode 100644 index 0000000..07dc20e --- /dev/null +++ b/static/js/utils/bbcode_display.js @@ -0,0 +1,276 @@ +document.addEventListener('DOMContentLoaded', () => { + let textarea = document.querySelector('textarea'); + const quoteButtons = document.querySelectorAll('#quote'); + console.log(quoteButtons); + quoteButtons.forEach((elem) =>{ + const post_id = elem.target; + elem.addEventListener('click', event => { + const author = document.querySelector(`#author-post-${post_id}`).textContent; + const content = document.querySelector(`#post-${post_id}`).textContent; + + textarea.value = `[citation=${author}]${content}[/citation]`; + textarea.focus(); + }); + }); + + let colorBarVisible = false; + let existingColorBar = null; + + fetch('/static/js/utils/bbcode-bar.html') + .then(response => { + if (response.ok) { + return response.text(); + } else { + throw new Error('Impossible de charger le fichier HTML'); + } + }) + .then(html => { + textarea.insertAdjacentHTML('beforebegin', html); + + const bbcodeBar = document.querySelector('.bbcode-bar'); + bbcodeBar.addEventListener('click', event => { + if (event.target.tagName === 'BUTTON' && event.target.classList.contains('bbcode-bar-item')) { + const tag = event.target.getAttribute('data-tag'); + if (tag.includes('][')) { + const [openTag, closeTag] = tag.split(']['); + let startTag = openTag + ']'; + if(tag === '[list][/list]') { + startTag = openTag + '][*]'; + } + const endTag = '[' + closeTag; + + const startPos = textarea.selectionStart; + const endPos = textarea.selectionEnd; + + const text = textarea.value; + + textarea.value = + text.slice(0, startPos) + + startTag + + text.slice(startPos, endPos) + + endTag + + text.slice(endPos); + + textarea.selectionStart = startPos + startTag.length; + textarea.selectionEnd = startPos + startTag.length; + + textarea.focus(); + } else { + console.error('Format de balise incorrect :', tag); + } + } else if (event.target.tagName === 'BUTTON' && event.target.classList.contains('bbcode-bar-button-gallery')) { + let draggableWindows = document.querySelector('.draggable'); + let header = draggableWindows.querySelector('.header'); + const contentArea = document.querySelector('.content-gallery') + fetch('/gallery/') + .then(response => { + if(response.ok) { + return response.text(); + } else { + throw new Error('Impossible de charger le fichier html de la galerie'); + } + }) + .then(html => { + contentArea.innerHTML = html; + draggableWindows.style.display = 'block'; + }) + .catch(error => { + contentArea.innerHTML = '

      Erreur lors du chargement de la galerie

      '; + draggableWindows.style.display = 'block'; + }) + + if (draggableWindows) { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const modalWidth = draggableWindows.offsetWidth; + const modalHeight = draggableWindows.offsetHeight; + + // Centre la fenêtre au milieu de l'écran + draggableWindows.style.left = `${(windowWidth - modalWidth) / 2}px`; + draggableWindows.style.top = `${(windowHeight - modalHeight) / 2}px`; + + // Assure que la fenêtre soit positionnée en mode absolu + draggableWindows.style.position = 'fixed'; + } + + if (draggableWindows) { + if (header) { + header.addEventListener('mousedown', (e) => { + e.preventDefault(); + let offsetX = e.clientX - draggableWindows.offsetLeft; + let offsetY = e.clientY - draggableWindows.offsetTop; + + function onMouseMove(e) { + draggableWindows.style.left = e.clientX - offsetX + 'px'; + draggableWindows.style.top = e.clientY - offsetY + 'px'; + } + + document.addEventListener('mousemove', onMouseMove); + + document.addEventListener('mouseup', () => { + document.removeEventListener('mousemove', onMouseMove); + }, { once: true }); + }); + } + + const closeButton = document.querySelector('#close-window'); + if (closeButton) { + closeButton.addEventListener('click', () => { + console.log("test fermeture") + draggableWindows.style.display = 'none'; + }); + } + } + } else if (event.target.tagName === 'BUTTON' && event.target.classList.contains('bbcode-bar-button-colors')) { + event.preventDefault(); + + if (existingColorBar) { + // Si la barre existe déjà, on bascule sa visibilité + existingColorBar.style.display = colorBarVisible ? 'none' : 'block'; + colorBarVisible = !colorBarVisible; + return; + } + + // Si la barre n'existe pas encore, on la crée + const colors = [ + // Basiques + 'black', + 'white', + + // Gris + 'gray', + 'darkgray', + 'silver', + + // Rouges + 'red', + 'darkred', + 'maroon', + 'crimson', + 'coral', + + // Roses/Violets + 'pink', + 'hotpink', + 'fuchsia', + 'purple', + 'blueviolet', + 'darkmagenta', + + // Bleus + 'blue', + 'navy', + 'darkblue', + 'royalblue', + 'cornflowerblue', + 'skyblue', + 'aqua', + 'cyan', + 'darkcyan', + 'teal', + + // Verts + 'green', + 'darkgreen', + 'lime', + 'limegreen', + 'springgreen', + 'aquamarine', + 'chartreuse', + + // Jaunes/Oranges + 'yellow', + 'gold', + 'orange', + 'darkorange', + + // Marrons + 'brown', + 'chocolate', + 'saddlebrown', + 'burlywood', + + // Tons pastel + 'aliceblue', + 'antiquewhite', + 'azure', + 'beige', + 'bisque', + 'blanchedalmond', + 'cornsilk', + 'darkkhaki', + 'olive' + ]; + + const colorBar = document.createElement('div'); + colorBar.classList.add('bbcode-bar-colors'); + colorBar.style.display = 'block'; + + const colorButtons = colors.map(color => { + const link = document.createElement('a'); + link.classList.add('bbcode-bar-item-color'); + link.style.backgroundColor = color; + link.style.width = '20px'; + link.style.height = '20px'; + link.style.display = 'inline-block'; + link.style.margin = '2px'; + link.setAttribute('data-color', color); + link.href = '#'; + return link; + }); + + colorButtons.forEach(link => colorBar.appendChild(link)); + bbcodeBar.appendChild(colorBar); + + existingColorBar = colorBar; + colorBarVisible = true; + } else if (event.target.tagName === 'A' && event.target.classList.contains('bbcode-bar-item-color')) { + event.preventDefault(); + const color = event.target.getAttribute('data-color'); + if (color) { + insertBBCode(`[color=${color}]`, `[/color]`); + } + } + }); + }) + + .catch(error => console.error('Erreur: ', error)); +}); + +function insertBBCode(openTag, closeTag) { + const textarea = document.querySelector('textarea'); // Assurez-vous de sélectionner le bon textarea + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const text = textarea.value; + + const before = text.substring(0, start); + const selected = text.substring(start, end); + const after = text.substring(end); + + textarea.value = `${before}${openTag}${selected}${closeTag}${after}`; + textarea.focus(); + textarea.selectionStart = start + openTag.length; + textarea.selectionEnd = end + openTag.length; +} + +function load_gallery() { + const contentArea = document.querySelector('.content-gallery') + fetch('/gallery/') + .then(response => { + if(response.ok) { + return response.text(); + } else { + throw new Error('Impossible de charger le fichier html de la galerie'); + } + }) + .then(html => { + contentArea.innerHTML = html; + draggableWindows.style.display = 'block'; + }) + .catch(error => { + contentArea.innerHTML = '

      Erreur lors du chargement de la galerie

      '; + draggableWindows.style.display = 'block'; + }) +} \ No newline at end of file diff --git a/static/js/utils/functions.js b/static/js/utils/functions.js new file mode 100644 index 0000000..cc20968 --- /dev/null +++ b/static/js/utils/functions.js @@ -0,0 +1,57 @@ +function openPopup(url) { + window.open( + url, + 'PopupWindow', + 'width=400,height=400,scrollbars=no,resizable=no' + ); +} + +function copyToClipboard(event) { + // Récupère le bouton qui a déclenché l'événement + const button = event.target; + + // Récupère la valeur de l'attribut data-tag + const tag = button.getAttribute('data-tag'); + + // Utilise l'API Clipboard pour copier dans le presse-papiers + navigator.clipboard.writeText(tag) +} + +function modal(id, price) { + const modal = document.querySelector('.modal'); + if (modal) { + modal.style.display = 'block'; + const close = document.querySelector('.modal-close'); + const accept = modal.querySelector('#accept'); + accept.innerHTML = `Payer ${price}`; + const decline = modal.querySelector('#decline'); + + accept.addEventListener('click', () => { + window.location.href = `/shop/buy/${id}`; + }); + + decline.addEventListener('click', () => { + modal.style.display = 'none'; + }); + + close.addEventListener('click', () => { + modal.style.display = 'none'; + }); + } +} + +document.addEventListener('DOMContentLoaded', () => { + const avatars = document.querySelectorAll('.cadre-retro-gameboy'); + avatars.forEach(avatar => { + console.log("on a trouvé l'avatar"); + const divButtons = document.createElement('div'); + const divCross = document.createElement('div'); + const divStartSelect = document.createElement('div'); + divButtons.classList.add('buttons'); + divCross.classList.add('controls'); + divStartSelect.classList.add('start-select'); + avatar.appendChild(divButtons); + avatar.appendChild(divCross); + avatar.appendChild(divStartSelect); + }); +}); \ No newline at end of file diff --git a/static/js/utils/multiple_posts.js b/static/js/utils/multiple_posts.js new file mode 100644 index 0000000..a74a6f6 --- /dev/null +++ b/static/js/utils/multiple_posts.js @@ -0,0 +1,87 @@ +const selectPostType = document.querySelector('#post_type'); +if (selectPostType) { + let postsList = []; + let divChildForm = null; + const container = document.querySelector('form'); + const buttonSubmit = document.querySelector('#submit-button'); + const divSubmit = document.querySelector('#submit'); + const addPostButton = document.createElement('a'); + + addPostButton.className = 'btn btn-add'; + addPostButton.textContent = "Ajouter un article"; + + selectPostType.addEventListener('change', (event) => { + const selectedValue = event.target.value; + + if (selectedValue === 'solo') { + // Supprime le bouton si présent + buttonSubmit.textContent = 'Créer'; + if (divSubmit.contains(addPostButton)) { + divSubmit.removeChild(addPostButton); + } + + // Supprime le conteneur des articles, s'il existe + if (divChildForm) { + container.removeChild(divChildForm); + divChildForm = null; // Réinitialise pour éviter des doublons + } + } else if (selectedValue === 'multiple') { + // Crée le conteneur des articles si non encore créé + buttonSubmit.textContent = 'Créer mon ensemble d\'articles'; + if (!divChildForm) { + const hiddenForm = document.createElement('input'); + hiddenForm.type = 'hidden'; + hiddenForm.name = 'type_form'; + hiddenForm.value = 'multiple'; + container.appendChild(hiddenForm); + + divChildForm = document.createElement('div'); + container.appendChild(divChildForm); + } + + // Ajoute le bouton si non encore ajouté + if (!divSubmit.contains(addPostButton)) { + divSubmit.appendChild(addPostButton); + } + + // Évite les doublons d'écouteurs avec "onclick" + addPostButton.onclick = () => { + // Ajoute un numéro d'article à la liste + const articleNumber = postsList.length + 1; + postsList.push(articleNumber); + + // Crée un titre pour l'article + const title = document.createElement('h3'); + title.textContent = `Article enfant #${articleNumber}`; + + const form = document.createElement('form'); + form.method = "POST"; + form.action = ""; + + const formTitle = document.createElement('input'); + formTitle.type = 'text'; + formTitle.name = `title-post-${articleNumber}`; + formTitle.placeholder = `Titre article #${articleNumber}`; + formTitle.required = true; + + const formContent = document.createElement('textarea'); + formContent.name = `content-post-${articleNumber}`; + formContent.placeholder = `Contenu article #${articleNumber}`; + formContent.rows = 10; + formContent.cols = 40; + formContent.required = true; + + const formPostType = document.createElement('input'); + formPostType.type = 'hidden'; + formPostType.name = `post-type-${articleNumber}`; + formPostType.value = 'child'; + + divChildForm.appendChild(title); + // divChildForm.appendChild(form); + divChildForm.appendChild(formTitle); + divChildForm.appendChild(formContent); + divChildForm.appendChild(formPostType); + }; + } + }); +} \ No newline at end of file diff --git a/static/js/vendor/jquery/LICENSE.txt b/static/js/vendor/jquery/LICENSE.txt new file mode 100644 index 0000000..f642c3f --- /dev/null +++ b/static/js/vendor/jquery/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/static/js/vendor/jquery/jquery.js b/static/js/vendor/jquery/jquery.js new file mode 100644 index 0000000..7f35c11 --- /dev/null +++ b/static/js/vendor/jquery/jquery.js @@ -0,0 +1,10965 @@ +/*! + * jQuery JavaScript Library v3.6.4 + * https://jquery.com/ + * + * Includes Sizzle.js + * https://sizzlejs.com/ + * + * Copyright OpenJS Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2023-03-08T15:28Z + */ +( function( global, factory ) { + + "use strict"; + + if ( typeof module === "object" && typeof module.exports === "object" ) { + + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket trac-14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +} )( typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Edge <= 12 - 13+, Firefox <=18 - 45+, IE 10 - 11, Safari 5.1 - 9+, iOS 6 - 9.1 +// throw exceptions when non-strict code (e.g., ASP.NET 4.5) accesses strict mode +// arguments.callee.caller (trac-13335). But as of jQuery 3.0 (2016), strict mode should be common +// enough that all such attempts are guarded in a try block. +"use strict"; + +var arr = []; + +var getProto = Object.getPrototypeOf; + +var slice = arr.slice; + +var flat = arr.flat ? function( array ) { + return arr.flat.call( array ); +} : function( array ) { + return arr.concat.apply( [], array ); +}; + + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var fnToString = hasOwn.toString; + +var ObjectFunctionString = fnToString.call( Object ); + +var support = {}; + +var isFunction = function isFunction( obj ) { + + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; + + +var isWindow = function isWindow( obj ) { + return obj != null && obj === obj.window; + }; + + +var document = window.document; + + + + var preservedScriptAttributes = { + type: true, + src: true, + nonce: true, + noModule: true + }; + + function DOMEval( code, node, doc ) { + doc = doc || document; + + var i, val, + script = doc.createElement( "script" ); + + script.text = code; + if ( node ) { + for ( i in preservedScriptAttributes ) { + + // Support: Firefox 64+, Edge 18+ + // Some browsers don't support the "nonce" property on scripts. + // On the other hand, just using `getAttribute` is not enough as + // the `nonce` attribute is reset to an empty string whenever it + // becomes browsing-context connected. + // See https://github.com/whatwg/html/issues/2369 + // See https://html.spec.whatwg.org/#nonce-attributes + // The `node.getAttribute` check was added for the sake of + // `jQuery.globalEval` so that it can fake a nonce-containing node + // via an object. + val = node[ i ] || node.getAttribute && node.getAttribute( i ); + if ( val ) { + script.setAttribute( i, val ); + } + } + } + doc.head.appendChild( script ).parentNode.removeChild( script ); + } + + +function toType( obj ) { + if ( obj == null ) { + return obj + ""; + } + + // Support: Android <=2.3 only (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; +} +/* global Symbol */ +// Defining this global in .eslintrc.json would create a danger of using the global +// unguarded in another place, it seems safer to define global only for this module + + + +var + version = "3.6.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }; + +jQuery.fn = jQuery.prototype = { + + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + + // Return all the elements in a clean array + if ( num == null ) { + return slice.call( this ); + } + + // Return just the one element from the set + return num < 0 ? this[ num + this.length ] : this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + each: function( callback ) { + return jQuery.each( this, callback ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + even: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return ( i + 1 ) % 2; + } ) ); + }, + + odd: function() { + return this.pushStack( jQuery.grep( this, function( _elem, i ) { + return i % 2; + } ) ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[ 0 ] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !isFunction( target ) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + + // Only deal with non-null/undefined values + if ( ( options = arguments[ i ] ) != null ) { + + // Extend the base object + for ( name in options ) { + copy = options[ name ]; + + // Prevent Object.prototype pollution + // Prevent never-ending loop + if ( name === "__proto__" || target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = Array.isArray( copy ) ) ) ) { + src = target[ name ]; + + // Ensure proper type for the source value + if ( copyIsArray && !Array.isArray( src ) ) { + clone = []; + } else if ( !copyIsArray && !jQuery.isPlainObject( src ) ) { + clone = {}; + } else { + clone = src; + } + copyIsArray = false; + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend( { + + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isPlainObject: function( obj ) { + var proto, Ctor; + + // Detect obvious negatives + // Use toString instead of jQuery.type to catch host objects + if ( !obj || toString.call( obj ) !== "[object Object]" ) { + return false; + } + + proto = getProto( obj ); + + // Objects with no prototype (e.g., `Object.create( null )`) are plain + if ( !proto ) { + return true; + } + + // Objects with prototype are plain iff they were constructed by a global Object function + Ctor = hasOwn.call( proto, "constructor" ) && proto.constructor; + return typeof Ctor === "function" && fnToString.call( Ctor ) === ObjectFunctionString; + }, + + isEmptyObject: function( obj ) { + var name; + + for ( name in obj ) { + return false; + } + return true; + }, + + // Evaluates a script in a provided context; falls back to the global one + // if not specified. + globalEval: function( code, options, doc ) { + DOMEval( code, { nonce: options && options.nonce }, doc ); + }, + + each: function( obj, callback ) { + var length, i = 0; + + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } else { + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; + } + } + } + + return obj; + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArrayLike( Object( arr ) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + // Support: Android <=4.0 only, PhantomJS 1 only + // push.apply(_, arraylike) throws on ancient WebKit + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var length, value, + i = 0, + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return flat( ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); + +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = arr[ Symbol.iterator ]; +} + +// Populate the class2type map +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); + +function isArrayLike( obj ) { + + // Support: real iOS 8.2 only (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, + type = toType( obj ); + + if ( isFunction( obj ) || isWindow( obj ) ) { + return false; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.3.10 + * https://sizzlejs.com/ + * + * Copyright JS Foundation and other contributors + * Released under the MIT license + * https://js.foundation/ + * + * Date: 2023-02-14 + */ +( function( window ) { +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + nonnativeSelectorCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // Instance methods + hasOwn = ( {} ).hasOwnProperty, + arr = [], + pop = arr.pop, + pushNative = arr.push, + push = arr.push, + slice = arr.slice, + + // Use a stripped-down indexOf as it's faster than native + // https://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[ i ] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|" + + "ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + identifier = "(?:\\\\[\\da-fA-F]{1,6}" + whitespace + + "?|\\\\[^\\r\\n\\f]|[\\w-]|[^\0-\\x7f])+", + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + + // "Attribute values must be CSS identifiers [capture 5] + // or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + + whitespace + "*\\]", + + pseudos = ":(" + identifier + ")(?:\\((" + + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + "*" ), + rdescend = new RegExp( whitespace + "|>" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rhtml = /HTML$/i, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + + // CSS escapes + // http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\[\\da-fA-F]{1,6}" + whitespace + "?|\\\\([^\\r\\n\\f])", "g" ), + funescape = function( escape, nonHex ) { + var high = "0x" + escape.slice( 1 ) - 0x10000; + + return nonHex ? + + // Strip the backslash prefix from a non-hex escape sequence + nonHex : + + // Replace a hexadecimal escape sequence with the encoded Unicode code point + // Support: IE <=11+ + // For values outside the Basic Multilingual Plane (BMP), manually construct a + // surrogate pair + high < 0 ? + String.fromCharCode( high + 0x10000 ) : + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // CSS string/identifier serialization + // https://drafts.csswg.org/cssom/#common-serializing-idioms + rcssescape = /([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g, + fcssescape = function( ch, asCodePoint ) { + if ( asCodePoint ) { + + // U+0000 NULL becomes U+FFFD REPLACEMENT CHARACTER + if ( ch === "\0" ) { + return "\uFFFD"; + } + + // Control characters and (dependent upon position) numbers get escaped as code points + return ch.slice( 0, -1 ) + "\\" + + ch.charCodeAt( ch.length - 1 ).toString( 16 ) + " "; + } + + // Other potentially-special ASCII characters get backslash-escaped + return "\\" + ch; + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }, + + inDisabledFieldset = addCombinator( + function( elem ) { + return elem.disabled === true && elem.nodeName.toLowerCase() === "fieldset"; + }, + { dir: "parentNode", next: "legend" } + ); + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + ( arr = slice.call( preferredDoc.childNodes ) ), + preferredDoc.childNodes + ); + + // Support: Android<4.0 + // Detect silently failing push.apply + // eslint-disable-next-line no-unused-expressions + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + pushNative.apply( target, slice.call( els ) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + + // Can't trust NodeList.length + while ( ( target[ j++ ] = els[ i++ ] ) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + setDocument( context ); + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && ( match = rquickExpr.exec( selector ) ) ) { + + // ID selector + if ( ( m = match[ 1 ] ) ) { + + // Document context + if ( nodeType === 9 ) { + if ( ( elem = context.getElementById( m ) ) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && ( elem = newContext.getElementById( m ) ) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[ 2 ] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( ( m = match[ 3 ] ) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !nonnativeSelectorCache[ selector + " " ] && + ( !rbuggyQSA || !rbuggyQSA.test( selector ) ) && + + // Support: IE 8 only + // Exclude object elements + ( nodeType !== 1 || context.nodeName.toLowerCase() !== "object" ) ) { + + newSelector = selector; + newContext = context; + + // qSA considers elements outside a scoping root when evaluating child or + // descendant combinators, which is not what we want. + // In such cases, we work around the behavior by prefixing every selector in the + // list with an ID selector referencing the scope context. + // The technique has to be used as well when a leading combinator is used + // as such selectors are not recognized by querySelectorAll. + // Thanks to Andrew Dupont for this technique. + if ( nodeType === 1 && + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + + // We can use :scope instead of the ID hack if the browser + // supports it & if we're not changing the context. + if ( newContext !== context || !support.scope ) { + + // Capture the context ID, setting it first if necessary + if ( ( nid = context.getAttribute( "id" ) ) ) { + nid = nid.replace( rcssescape, fcssescape ); + } else { + context.setAttribute( "id", ( nid = expando ) ); + } + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + while ( i-- ) { + groups[ i ] = ( nid ? "#" + nid : ":scope" ) + " " + + toSelector( groups[ i ] ); + } + newSelector = groups.join( "," ); + } + + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + nonnativeSelectorCache( selector, true ); + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {function(string, object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return ( cache[ key + " " ] = value ); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created element and returns a boolean result + */ +function assert( fn ) { + var el = document.createElement( "fieldset" ); + + try { + return !!fn( el ); + } catch ( e ) { + return false; + } finally { + + // Remove from its parent by default + if ( el.parentNode ) { + el.parentNode.removeChild( el ); + } + + // release memory in IE + el = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split( "|" ), + i = arr.length; + + while ( i-- ) { + Expr.attrHandle[ arr[ i ] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + a.sourceIndex - b.sourceIndex; + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( ( cur = cur.nextSibling ) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return ( name === "input" || name === "button" ) && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for :enabled/:disabled + * @param {Boolean} disabled true for :disabled; false for :enabled + */ +function createDisabledPseudo( disabled ) { + + // Known :disabled false positives: fieldset[disabled] > legend:nth-of-type(n+2) :can-disable + return function( elem ) { + + // Only certain elements can match :enabled or :disabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-enabled + // https://html.spec.whatwg.org/multipage/scripting.html#selector-disabled + if ( "form" in elem ) { + + // Check for inherited disabledness on relevant non-disabled elements: + // * listed form-associated elements in a disabled fieldset + // https://html.spec.whatwg.org/multipage/forms.html#category-listed + // https://html.spec.whatwg.org/multipage/forms.html#concept-fe-disabled + // * option elements in a disabled optgroup + // https://html.spec.whatwg.org/multipage/forms.html#concept-option-disabled + // All such elements have a "form" property. + if ( elem.parentNode && elem.disabled === false ) { + + // Option elements defer to a parent optgroup if present + if ( "label" in elem ) { + if ( "label" in elem.parentNode ) { + return elem.parentNode.disabled === disabled; + } else { + return elem.disabled === disabled; + } + } + + // Support: IE 6 - 11 + // Use the isDisabled shortcut property to check for disabled fieldset ancestors + return elem.isDisabled === disabled || + + // Where there is no isDisabled, check manually + /* jshint -W018 */ + elem.isDisabled !== !disabled && + inDisabledFieldset( elem ) === disabled; + } + + return elem.disabled === disabled; + + // Try to winnow out elements that can't be disabled before trusting the disabled property. + // Some victims get caught in our net (label, legend, menu, track), but it shouldn't + // even exist on them, let alone have a boolean value. + } else if ( "label" in elem ) { + return elem.disabled === disabled; + } + + // Remaining elements are neither :enabled nor :disabled + return false; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction( function( argument ) { + argument = +argument; + return markFunction( function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ ( j = matchIndexes[ i ] ) ] ) { + seed[ j ] = !( matches[ j ] = seed[ j ] ); + } + } + } ); + } ); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; + + // Support: IE <=8 + // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes + // https://bugs.jquery.com/ticket/4833 + return !rhtml.test( namespace || docElem && docElem.nodeName || "HTML" ); +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, subWindow, + doc = node ? node.ownerDocument || node : preferredDoc; + + // Return early if doc is invalid or already selected + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( doc == document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Update global variables + document = doc; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); + + // Support: IE 9 - 11+, Edge 12 - 18+ + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + // Support: IE 11+, Edge 17 - 18+ + // IE/Edge sometimes throw a "Permission denied" error when strict-comparing + // two documents; shallow comparisons work. + // eslint-disable-next-line eqeqeq + if ( preferredDoc != document && + ( subWindow = document.defaultView ) && subWindow.top !== subWindow ) { + + // Support: IE 11, Edge + if ( subWindow.addEventListener ) { + subWindow.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( subWindow.attachEvent ) { + subWindow.attachEvent( "onunload", unloadHandler ); + } + } + + // Support: IE 8 - 11+, Edge 12 - 18+, Chrome <=16 - 25 only, Firefox <=3.6 - 31 only, + // Safari 4 - 5 only, Opera <=11.6 - 12.x only + // IE/Edge & older browsers don't support the :scope pseudo-class. + // Support: Safari 6.0 only + // Safari 6.0 supports :scope but it's an alias of :root there. + support.scope = assert( function( el ) { + docElem.appendChild( el ).appendChild( document.createElement( "div" ) ); + return typeof el.querySelectorAll !== "undefined" && + !el.querySelectorAll( ":scope fieldset div" ).length; + } ); + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert( function( el ) { + el.className = "i"; + return !el.getAttribute( "className" ); + } ); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert( function( el ) { + el.appendChild( document.createComment( "" ) ); + return !el.getElementsByTagName( "*" ).length; + } ); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programmatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert( function( el ) { + docElem.appendChild( el ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; + } ); + + // ID filter and find + if ( support.getById ) { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute( "id" ) === attrId; + }; + }; + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var elem = context.getElementById( id ); + return elem ? [ elem ] : []; + } + }; + } else { + Expr.filter[ "ID" ] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode( "id" ); + return node && node.value === attrId; + }; + }; + + // Support: IE 6 - 7 only + // getElementById is not reliable as a find shortcut + Expr.find[ "ID" ] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var node, i, elems, + elem = context.getElementById( id ); + + if ( elem ) { + + // Verify the id attribute + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + + // Fall back on getElementsByName + elems = context.getElementsByName( id ); + i = 0; + while ( ( elem = elems[ i++ ] ) ) { + node = elem.getAttributeNode( "id" ); + if ( node && node.value === id ) { + return [ elem ]; + } + } + } + + return []; + } + }; + } + + // Tag + Expr.find[ "TAG" ] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( ( elem = results[ i++ ] ) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find[ "CLASS" ] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See https://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( ( support.qsa = rnative.test( document.querySelectorAll ) ) ) { + + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert( function( el ) { + + var input; + + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // https://bugs.jquery.com/ticket/12359 + docElem.appendChild( el ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // https://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( el.querySelectorAll( "[msallowcapture^='']" ).length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !el.querySelectorAll( "[selected]" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !el.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push( "~=" ); + } + + // Support: IE 11+, Edge 15 - 18+ + // IE 11/Edge don't find elements on a `[name='']` query in some cases. + // Adding a temporary attribute to the document before the selection works + // around the issue. + // Interestingly, IE 10 & older don't seem to have the issue. + input = document.createElement( "input" ); + input.setAttribute( "name", "" ); + el.appendChild( input ); + if ( !el.querySelectorAll( "[name='']" ).length ) { + rbuggyQSA.push( "\\[" + whitespace + "*name" + whitespace + "*=" + + whitespace + "*(?:''|\"\")" ); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !el.querySelectorAll( ":checked" ).length ) { + rbuggyQSA.push( ":checked" ); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibling-combinator selector` fails + if ( !el.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push( ".#.+[+~]" ); + } + + // Support: Firefox <=3.6 - 5 only + // Old Firefox doesn't throw on a badly-escaped identifier. + el.querySelectorAll( "\\\f" ); + rbuggyQSA.push( "[\\r\\n\\f]" ); + } ); + + assert( function( el ) { + el.innerHTML = "" + + ""; + + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement( "input" ); + input.setAttribute( "type", "hidden" ); + el.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( el.querySelectorAll( "[name=d]" ).length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( el.querySelectorAll( ":enabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: IE9-11+ + // IE's :disabled selector does not pick up the children of disabled fieldsets + docElem.appendChild( el ).disabled = true; + if ( el.querySelectorAll( ":disabled" ).length !== 2 ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Support: Opera 10 - 11 only + // Opera 10-11 does not throw on post-comma invalid pseudos + el.querySelectorAll( "*,:x" ); + rbuggyQSA.push( ",.*:" ); + } ); + } + + if ( ( support.matchesSelector = rnative.test( ( matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector ) ) ) ) { + + assert( function( el ) { + + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( el, "*" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( el, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + } ); + } + + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully self-exclusive + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `