diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..2ffc09a
--- /dev/null
+++ b/Caddyfile
@@ -0,0 +1,18 @@
+http://hello.localhost {
+
+ root * /usr/src/app/
+ encode gzip zstd
+
+ @notStatic {
+ not path /static/* /media/*
+ }
+
+ reverse_proxy @notStatic django:8000
+
+ file_server /static/*
+ file_server /media/*
+}
+
+http://webmail.localhost {
+ reverse_proxy mailhog:8025
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..ed0eeca
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,16 @@
+# Image
+FROM python:3.10
+
+# Display the Python output through the terminal
+ENV PYTHONUNBUFFERED: 1
+
+# Set work directory
+WORKDIR /usr/src/app
+
+# Add Python dependencies
+## Update pip
+RUN pip install --upgrade pip
+## Copy requirements
+COPY requirements.txt ./requirements.txt
+## Install requirements
+RUN pip3 install -r requirements.txt
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/app_template/__init__.py b/app/app_template/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/app_template/actions.py b/app/app_template/actions.py
new file mode 100644
index 0000000..076fd61
--- /dev/null
+++ b/app/app_template/actions.py
@@ -0,0 +1,116 @@
+from .forms import LoginForm, SignupForm
+from asgiref.sync import async_to_sync
+from django.template.loader import render_to_string
+from django.urls import reverse
+from channels.auth import login, logout
+from django.contrib.auth.models import User
+from django.contrib.auth import authenticate
+from datetime import datetime
+
+
+def send_page(self, page):
+ """Render HTML and send page to client"""
+
+ # Prepare context data for page
+ context = {}
+ match page:
+ case "login":
+ context = {"form": LoginForm()}
+ case "signup":
+ context = {"form": SignupForm()}
+
+ # Add user to context if logged in
+ if "user" in self.scope:
+ context.update({ "user": self.scope["user"]})
+ context.update({"active_nav": page})
+
+ # Render HTML nav and send to client
+ self.send_html({
+ "selector": "#nav",
+ "html": render_to_string("components/_nav.html", context),
+ })
+
+ # Render HTML page and send to client
+ self.send_html({
+ "selector": "#main",
+ "html": render_to_string(f"pages/{page}.html", context),
+ "url": reverse(page),
+ })
+
+ # Hidrate page
+ match page:
+ case "home":
+ update_TODO(self)
+
+
+def action_signup(self, data):
+ """Sign up user"""
+ form = SignupForm(data)
+ user_exist = User.objects.filter(email=data["email"]).exists()
+ if form.is_valid() and data["password"] == data["password_confirm"] and not user_exist:
+ # Create user
+ user = User.objects.create_user(data["username"], data["email"], data["password"])
+ user.is_active = True
+ user.save()
+ # Login user
+ send_page(self, "login")
+ else:
+ # Send form errors
+ self.send_html({
+ "selector": "#main",
+ "html": render_to_string("pages/signup.html", {"form": form, "user_exist": user_exist, "passwords_do_not_match": data["password"] != data["password_confirm"]}),
+ "append": False,
+ "url": reverse("signup")
+ })
+
+
+def action_login(self, data):
+ """Log in user"""
+ form = LoginForm(data)
+ user = authenticate(username=data["email"], password=data["password"])
+ if form.is_valid() and user:
+ async_to_sync(login)(self.scope, user)
+ self.scope["session"].save()
+ send_page(self, "profile")
+ else:
+ self.send_html({
+ "selector": "#main",
+ "html": render_to_string("pages/login.html", {"form": form, "user_does_not_exist": user is None}),
+ "append": False,
+ "url": reverse("login")
+ })
+
+
+def action_logout(self):
+ """Log out user"""
+ async_to_sync(logout)(self.scope)
+ self.scope["session"].save()
+ send_page(self, "login")
+
+
+def add_lap(self):
+ """Add lap to Home page"""
+ # Send current time to client
+ self.send_html({
+ "selector": "#laps",
+ "html": render_to_string("components/_lap.html", {"time": datetime.now()}),
+ "append": True,
+ })
+
+
+def add_task(self, data):
+ """Add task from TODO section"""
+ # Add task to list
+ self.scope["session"]["tasks"].append(data["task"])
+ self.scope["session"].save()
+ # Update task list
+ update_TODO(self)
+
+
+def update_TODO(self):
+ """Update TODO list"""
+ self.send_html({
+ "selector": "#todo",
+ "html": render_to_string("components/_tasks.html", {"tasks": self.scope["session"]["tasks"]}),
+ "append": False,
+ })
\ No newline at end of file
diff --git a/app/app_template/apps.py b/app/app_template/apps.py
new file mode 100644
index 0000000..cb99fb3
--- /dev/null
+++ b/app/app_template/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class SimpleAppConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "app.app_template"
diff --git a/app/app_template/backends.py b/app/app_template/backends.py
new file mode 100644
index 0000000..bcb2d21
--- /dev/null
+++ b/app/app_template/backends.py
@@ -0,0 +1,35 @@
+from django.contrib.auth.models import User
+from django.contrib.auth.backends import BaseBackend
+
+
+class EmailBackend(BaseBackend):
+ """
+ Email authentication backend
+ """
+
+ def authenticate(self, request, username=None, password=None, **kwargs):
+ """
+ Authenticate a user based on email address as the user name.
+ """
+ if "@" in username:
+ kwargs = {"email": username}
+ else:
+ kwargs = {"username": username}
+ try:
+ user = User.objects.get(**kwargs)
+ if user.check_password(password):
+ return user
+ except User.DoesNotExist:
+ return None
+
+ def get_user(self, user_id):
+ try:
+ return User.objects.get(pk=user_id)
+ except User.DoesNotExist:
+ return None
+
+
+# Future re-implementation with token for auto login
+class TokenBackend(BaseBackend):
+ def authenticate(self, request, token=None):
+ pass
diff --git a/app/app_template/consumers.py b/app/app_template/consumers.py
new file mode 100644
index 0000000..9e7f295
--- /dev/null
+++ b/app/app_template/consumers.py
@@ -0,0 +1,55 @@
+# app/app_template/consumers.py
+from channels.generic.websocket import JsonWebsocketConsumer
+import app.app_template.actions as actions
+
+
+class ExampleConsumer(JsonWebsocketConsumer):
+
+ def connect(self):
+ """Event when client connects"""
+ # Accept the connection
+ self.accept()
+ # Make session task list
+ if "tasks" not in self.scope["session"]:
+ self.scope["session"]["tasks"] = []
+ self.scope["session"].save()
+
+
+ def disconnect(self, close_code):
+ """Event when client disconnects"""
+ pass
+
+ def receive_json(self, data_received):
+ """
+ Event when data is received
+ All information will arrive in 2 variables:
+ "action", with the action to be taken
+ "data" with the information
+ """
+ # Get the data
+ data = data_received["data"]
+ # Depending on the action we will do one task or another.
+ match data_received["action"]:
+ case "Change page":
+ actions.send_page(self, data["page"])
+ case "Signup":
+ actions.action_signup(self, data)
+ case "Login":
+ actions.action_login(self, data)
+ case "Logout":
+ actions.action_logout(self)
+ case "Add lap":
+ actions.add_lap(self)
+ case "Add task":
+ actions.add_task(self, data)
+
+
+ def send_html(self, event):
+ """Event: Send html to client"""
+ data = {
+ "selector": event["selector"],
+ "html": event["html"],
+ "append": "append" in event and event["append"],
+ "url": event["url"] if "url" in event else "",
+ }
+ self.send_json(data)
\ No newline at end of file
diff --git a/app/app_template/forms.py b/app/app_template/forms.py
new file mode 100644
index 0000000..85332fd
--- /dev/null
+++ b/app/app_template/forms.py
@@ -0,0 +1,39 @@
+from django import forms
+
+
+class LoginForm(forms.Form):
+ email = forms.CharField(
+ label="Email",
+ max_length=255,
+ widget=forms.EmailInput(attrs={"id": "login-email", "class": "input"}),
+ )
+ password = forms.CharField(
+ label="Password",
+ max_length=255,
+ widget=forms.PasswordInput(attrs={"id": "login-password", "class": "input"}),
+ )
+
+
+class SignupForm(forms.Form):
+ username = forms.CharField(
+ label="Username",
+ max_length=255,
+ widget=forms.TextInput(attrs={"id": "signup-username", "class": "input"}),
+ )
+ email = forms.EmailField(
+ label="Email",
+ max_length=255,
+ widget=forms.EmailInput(attrs={"id": "signup-email", "class": "input"}),
+ )
+ password = forms.CharField(
+ label="Password",
+ max_length=255,
+ widget=forms.PasswordInput(attrs={"id": "signup-password", "class": "input"}),
+ )
+ password_confirm = forms.CharField(
+ label="Confirm Password",
+ max_length=255,
+ widget=forms.PasswordInput(
+ attrs={"id": "signup-password-confirm", "class": "input"}
+ ),
+ )
diff --git a/app/app_template/migrations/__init__.py b/app/app_template/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/app_template/models.py b/app/app_template/models.py
new file mode 100644
index 0000000..71a8362
--- /dev/null
+++ b/app/app_template/models.py
@@ -0,0 +1,3 @@
+from django.db import models
+
+# Create your models here.
diff --git a/app/app_template/templates/base.html b/app/app_template/templates/base.html
new file mode 100644
index 0000000..c27346a
--- /dev/null
+++ b/app/app_template/templates/base.html
@@ -0,0 +1,23 @@
+{% load static %}
+
+
+
+
+
+ Example website
+
+
+
+
+
+
+ {% include 'components/_nav.html' %}
+
+ {% include page %}
+
+
+
+
\ No newline at end of file
diff --git a/app/app_template/templates/components/_nav.html b/app/app_template/templates/components/_nav.html
new file mode 100644
index 0000000..ae564ae
--- /dev/null
+++ b/app/app_template/templates/components/_nav.html
@@ -0,0 +1,54 @@
+
+ {# Links always visible #}
+
+
+ Home
+
+
+{% if user.is_authenticated %}
+ {# Links only if identified #}
+
+
+ Profile
+
+
+
+
+ Logout
+
+
+{% else %}
+ {# Links only if not identified #}
+
+
+ Login
+
+
+
+
+ Signup
+
+
+{% endif %}
+
\ No newline at end of file
diff --git a/app/app_template/templates/components/_tasks.html b/app/app_template/templates/components/_tasks.html
new file mode 100644
index 0000000..5b563ac
--- /dev/null
+++ b/app/app_template/templates/components/_tasks.html
@@ -0,0 +1,3 @@
+{% for task in tasks %}
+ {{ task }}
+{% endfor %}
\ No newline at end of file
diff --git a/app/app_template/templates/pages/404.html b/app/app_template/templates/pages/404.html
new file mode 100644
index 0000000..79c4fa5
--- /dev/null
+++ b/app/app_template/templates/pages/404.html
@@ -0,0 +1 @@
+404
\ No newline at end of file
diff --git a/app/app_template/templates/pages/home.html b/app/app_template/templates/pages/home.html
new file mode 100644
index 0000000..d0faa43
--- /dev/null
+++ b/app/app_template/templates/pages/home.html
@@ -0,0 +1,17 @@
+
+ Welcome to an example of browsing with WebSockets over the Wire
+ You will be able to experience a simple structure with a registration, a login and a private page.
+
+
+
diff --git a/app/app_template/templates/pages/login.html b/app/app_template/templates/pages/login.html
new file mode 100644
index 0000000..ae73b35
--- /dev/null
+++ b/app/app_template/templates/pages/login.html
@@ -0,0 +1,8 @@
+Login
+
\ No newline at end of file
diff --git a/app/app_template/templates/pages/profile.html b/app/app_template/templates/pages/profile.html
new file mode 100644
index 0000000..ddfbdae
--- /dev/null
+++ b/app/app_template/templates/pages/profile.html
@@ -0,0 +1,5 @@
+Profile
+Username
+{{ user.username }}
+Email
+{{ user.email }}
\ No newline at end of file
diff --git a/app/app_template/templates/pages/signup.html b/app/app_template/templates/pages/signup.html
new file mode 100644
index 0000000..1264d03
--- /dev/null
+++ b/app/app_template/templates/pages/signup.html
@@ -0,0 +1,11 @@
+Signup
+
\ No newline at end of file
diff --git a/app/app_template/views.py b/app/app_template/views.py
new file mode 100644
index 0000000..69feb3b
--- /dev/null
+++ b/app/app_template/views.py
@@ -0,0 +1,41 @@
+from django.shortcuts import render, redirect
+from .forms import LoginForm, SignupForm
+from django.contrib.auth.decorators import login_required
+
+
+def home(request):
+ return render(
+ request,
+ "base.html",
+ {
+ "page": "pages/home.html",
+ "active_nav": "home",
+ },
+ )
+
+
+def login(request):
+ return render(
+ request,
+ "base.html",
+ {"page": "pages/login.html", "active_nav": "login", "form": LoginForm()},
+ )
+
+
+def signup(request):
+ return render(
+ request,
+ "base.html",
+ {"page": "pages/signup.html", "active_nav": "signup", "form": SignupForm()},
+ )
+
+
+@login_required
+def profile(request):
+ return render(
+ request, "base.html", {"page": "pages/profile.html", "active_nav": "profile"}
+ )
+
+
+def page_not_found(request, exception):
+ return render(request, "base.html", {"page": "pages/404.html"})
diff --git a/django-launcher.sh b/django-launcher.sh
new file mode 100644
index 0000000..a829c76
--- /dev/null
+++ b/django-launcher.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+# Collect static files
+python3 manage.py collectstatic --noinput
+
+# Apply database migrations
+python3 manage.py migrate
+
+# Start server with debug mode
+python3 manage.py runserver 0.0.0.0:8000
+# Start server with production mode
+#daphne -b 0.0.0.0 -p 8000 project_template.asgi:application
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..ef3e7ff
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,74 @@
+version: '3.8'
+
+services:
+
+ django:
+ build:
+ context: ./
+ dockerfile: ./Dockerfile
+ entrypoint: bash ./django-launcher.sh
+ volumes:
+ - .:/usr/src/app/
+ environment:
+ DEBUG: "True"
+ ALLOWED_HOSTS: hello.localhost
+ SECRET_KEY: mysecret
+ DB_ENGINE: django.db.backends.postgresql
+ DB_NAME: hello_db
+ DB_USER: postgres
+ DB_PASSWORD: postgres
+ DB_HOST: postgresql
+ DB_PORT: 5432
+ DOMAIN: hello.localhost
+ DOMAIN_URL: http://hello.localhost
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ STATIC_URL: /static/
+ STATIC_ROOT: static
+ MEDIA_URL: /media/
+ DEFAULT_FROM_EMAIL: no-reply@hello.localhost
+ EMAIL_HOST: mailhog
+ EMAIL_USE_TLS: "False"
+ EMAIL_USE_SSL: "False"
+ EMAIL_PORT: 1025
+ EMAIL_USER:
+ EMAIL_PASSWORD:
+ expose:
+ - 8000
+ depends_on:
+ - postgresql
+ - redis
+
+ postgresql:
+ image: postgres
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: hello_db
+ volumes:
+ - ./postgres_data:/var/lib/postgresql/data/
+ expose:
+ - 5432
+
+ redis:
+ image: redis:alpine
+ expose:
+ - 6379
+
+ caddy:
+ image: caddy:alpine
+ ports:
+ - 80:80
+ - 443:443
+ volumes:
+ - .:/usr/src/app/
+ - ./Caddyfile:/etc/caddy/Caddyfile
+ - ./caddy_data:/data
+ depends_on:
+ - django
+
+ mailhog:
+ image: mailhog/mailhog:latest
+ expose:
+ - 1025
+ - 8025
\ No newline at end of file
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..17fcc45
--- /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", "project_template.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/project_template/__init__.py b/project_template/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/project_template/asgi.py b/project_template/asgi.py
new file mode 100644
index 0000000..d5ceabb
--- /dev/null
+++ b/project_template/asgi.py
@@ -0,0 +1,29 @@
+# project_template/asgi.py
+import os
+import django
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project_template.settings")
+from django.conf import settings
+django.setup()
+from django.core.asgi import get_asgi_application
+from channels.security.websocket import OriginValidator
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
+from django.urls import re_path
+from app.app_template.consumers import ExampleConsumer
+
+
+application = ProtocolTypeRouter(
+ {
+ # Django's ASGI application to handle traditional HTTP requests
+ "http": get_asgi_application(),
+ # WebSocket handler
+ "websocket": OriginValidator(AuthMiddlewareStack(
+ URLRouter(
+ [
+ re_path(r"^ws/example/$", ExampleConsumer.as_asgi()),
+ ]
+ )
+ ), settings.ALLOWED_HOSTS)
+ }
+)
diff --git a/project_template/settings.py b/project_template/settings.py
new file mode 100644
index 0000000..a7d8959
--- /dev/null
+++ b/project_template/settings.py
@@ -0,0 +1,160 @@
+"""
+Django settings for project_template project.
+
+Generated by 'django-admin startproject' using Django 4.0.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/4.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/4.0/ref/settings/
+"""
+import os
+from pathlib import Path
+
+# 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.0/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = os.environ.get("SECRET_KEY")
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = os.environ.get("DEBUG", "True") == "True"
+
+ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS").split(",")
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ "channels",
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "app.app_template",
+]
+
+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",
+]
+
+ROOT_URLCONF = "project_template.urls"
+
+TEMPLATES = [
+ {
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "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",
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = "project_template.wsgi.application"
+
+
+# Database
+# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
+
+DATABASES = {
+ "default": {
+ "ENGINE": os.environ.get("DB_ENGINE"),
+ "NAME": os.environ.get("DB_NAME"),
+ "USER": os.environ.get("DB_USER"),
+ "PASSWORD": os.environ.get("DB_PASSWORD"),
+ "HOST": os.environ.get("DB_HOST"),
+ "PORT": os.environ.get("DB_PORT"),
+ }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/4.0/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.0/topics/i18n/
+
+LANGUAGE_CODE = "en-us"
+
+TIME_ZONE = "UTC"
+
+USE_I18N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/4.0/howto/static-files/
+
+STATIC_ROOT = os.environ.get("STATIC_ROOT")
+STATIC_URL = os.environ.get("STATIC_URL")
+MEDIA_ROOT = os.path.join(BASE_DIR, "media")
+MEDIA_URL = os.environ.get("MEDIA_URL")
+
+DOMAIN = os.environ.get("DOMAIN")
+DOMAIN_URL = os.environ.get("DOMAIN_URL")
+CSRF_TRUSTED_ORIGINS = [DOMAIN_URL]
+
+"""EMAIL CONFIG"""
+DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_ADDRESS")
+EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS") == "True"
+EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL") == "True"
+EMAIL_HOST = os.environ.get("EMAIL_HOST")
+EMAIL_PORT = os.environ.get("EMAIL_PORT")
+EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER")
+EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD")
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+CHANNEL_LAYERS = {
+ "default": {
+ "BACKEND": "channels_redis.core.RedisChannelLayer",
+ "CONFIG": {
+ "hosts": [(os.environ.get("REDIS_HOST"), os.environ.get("REDIS_PORT"))],
+ },
+ },
+}
+
+ASGI_APPLICATION = "project_template.asgi.application"
+
+AUTHENTICATION_BACKENDS = (
+ 'app.app_template.backends.EmailBackend',
+)
diff --git a/project_template/urls.py b/project_template/urls.py
new file mode 100644
index 0000000..1c80140
--- /dev/null
+++ b/project_template/urls.py
@@ -0,0 +1,28 @@
+"""project_template URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+ https://docs.djangoproject.com/en/4.0/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.urls import path
+from app.app_template import views
+
+urlpatterns = [
+ path("", views.home, name="home"),
+ path("login/", views.login, name="login"),
+ path("signup/", views.signup, name="signup"),
+ path("profile/", views.profile, name="profile"),
+ path("admin/", admin.site.urls),
+]
+
+handler404 = "app.app_template.views.page_not_found"
\ No newline at end of file
diff --git a/project_template/wsgi.py b/project_template/wsgi.py
new file mode 100644
index 0000000..d3f9652
--- /dev/null
+++ b/project_template/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for project_template 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.0/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project_template.settings")
+
+application = get_wsgi_application()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..801ea96
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,15 @@
+# Django
+django===4.0
+# Django Server
+daphne===3.0.2
+asgiref===3.4.1
+# Manipulate images
+Pillow===8.2.0
+# Kit utilities
+django-extensions===3.1.3
+# PostgreSQL driver
+psycopg2===2.9.1
+# Django Channels
+channels==3.0.4
+# Redis Layer
+channels_redis===3.2.0
\ No newline at end of file
diff --git a/static/css/main.css b/static/css/main.css
new file mode 100755
index 0000000..68dfdb9
--- /dev/null
+++ b/static/css/main.css
@@ -0,0 +1,69 @@
+
+
+/* Global styles */
+:root {
+ --color__background: #f6f4f3;
+ --color__gray: #ccc;
+ --color__black: #000;
+ --color__active: #00a0ff;
+}
+
+* {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background-color: var(--color__background);
+}
+
+/* General classes for small components */
+
+.container {
+ margin: 0 auto;
+ padding: 1rem 0;
+ max-width: 40rem;
+}
+
+.nav__ul {
+ display: flex;
+ list-style: none;
+ padding: 0;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.nav__link.active {
+ color: var(--color__active);
+ text-decoration: none;
+}
+
+.button {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ background-color: var(--color__gray);
+ border: 0;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.button:hover {
+ filter: brightness(90%);
+}
+
+.input {
+ display: block;
+ width: 100%;
+ outline: none;
+ padding: .5rem;
+ resize: none;
+ border: 1px solid var(--color__gray);
+ box-sizing: border-box;
+}
+
+.footer {
+ margin-top: 1rem;
+ text-align: center;
+}
\ No newline at end of file
diff --git a/static/js/controllers/add_form_controller.js b/static/js/controllers/add_form_controller.js
new file mode 100644
index 0000000..d0a55c6
--- /dev/null
+++ b/static/js/controllers/add_form_controller.js
@@ -0,0 +1,28 @@
+import { Controller } from "../vendors/stimulus.js"
+import { sendData } from "../webSocketsCli.js"
+
+export default class extends Controller {
+
+ static targets = [ "author", "text" ]
+
+ /**
+ * Send new message
+ * @param {Event} event
+ * @return {void}
+ */
+ add(event) {
+ event.preventDefault();
+ // Prepare the information we will send
+ const newData = {
+ "action": "add message",
+ "data": {
+ "author": this.authorTarget.value,
+ "text": this.textTarget.value
+ }
+ };
+ // Send the data to the server
+ sendData(newData, window.myWebSocket);
+ // Clear message form
+ this.textTarget.value = "";
+ }
+}
\ No newline at end of file
diff --git a/static/js/controllers/message_controller.js b/static/js/controllers/message_controller.js
new file mode 100644
index 0000000..dae9c20
--- /dev/null
+++ b/static/js/controllers/message_controller.js
@@ -0,0 +1,116 @@
+import { Controller } from "../vendors/stimulus.js"
+import { sendData } from "../webSocketsCli.js"
+
+export default class extends Controller {
+
+ static targets = [ "item", "paginator" ]
+
+ connect() {
+ this.enableInfiniteScroll();
+ }
+
+ /*
+ FUNCTIONS
+ */
+
+ /**
+ * Switches to the next page when the last message is displayed.
+ */
+ enableInfiniteScroll() {
+ const lastMessage = this.itemTargets.at(-1);
+ // Turn the page when the last message is displayed.
+ const observerLastMessage = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ if (!this.isLastPage()) this.goToNextPage();
+ }
+ });
+ });
+
+ observerLastMessage.observe(lastMessage);
+ }
+
+
+ /**
+ * Get current page stored in #paginator as dataset
+ * @returns {number}
+ */
+ getCurrentPage() {
+ return parseInt(this.paginatorTarget.dataset.page);
+ }
+
+ /**
+ * Check if we are on the last page
+ * @returns {boolean}
+ */
+ isLastPage() {
+ return parseInt(this.paginatorTarget.dataset.totalPages) === this.getCurrentPage();
+ }
+
+
+ /**
+ * Switch to the next page
+ * @param {Event} event
+ * @return {void}
+ */
+ goToNextPage(event) {
+ // Prepare the information we will send
+ const newData = {
+ "action": "list messages",
+ "data": {
+ "page": this.getCurrentPage() + 1,
+ }
+ };
+ // Send the data to the server
+ sendData(newData, myWebSocket);
+ }
+
+ /**
+ * Displays the update form
+ * @param {Event} event
+ * @return {void}
+ */
+ displayUpdateForm(event) {
+ const message = {
+ "action": "open edit page",
+ "data": {
+ "id": event.target.dataset.id
+ }
+ };
+ sendData(message, window.myWebSocket);
+ }
+
+
+ /**
+ * Update message
+ * @param {Event} event
+ * @return {void}
+ */
+ updateMessage(event) {
+ event.preventDefault();
+ const message = {
+ "action": "update message",
+ "data": {
+ "id": event.target.dataset.id,
+ "author": event.target.querySelector("#message-form__author--update").value,
+ "text": event.target.querySelector("#message-form__text--update").value
+ }
+ };
+ sendData(message, myWebSocket);
+ }
+
+ /**
+ * Delete message
+ * @param {Event} event
+ * @return {void}
+ */
+ deleteMessage(event) {
+ const message = {
+ "action": "delete message",
+ "data": {
+ "id": event.target.dataset.id
+ }
+ };
+ sendData(message, window.myWebSocket);
+ }
+}
\ No newline at end of file
diff --git a/static/js/controllers/navbarController.js b/static/js/controllers/navbarController.js
new file mode 100644
index 0000000..030908f
--- /dev/null
+++ b/static/js/controllers/navbarController.js
@@ -0,0 +1,28 @@
+import { Controller } from "../vendors/stimulus.js"
+import { sendData } from "../webSocketsCli.js"
+
+export default class extends Controller {
+
+ static targets = [ "page" ]
+
+ /**
+ * Send new message
+ * @param {Event} event
+ * @return {void}
+ */
+ add(event) {
+ event.preventDefault();
+ // Prepare the information we will send
+ const newData = {
+ "action": "add message",
+ "data": {
+ "author": this.authorTarget.value,
+ "text": this.textTarget.value
+ }
+ };
+ // Send the data to the server
+ sendData(newData, window.myWebSocket);
+ // Clear message form
+ this.textTarget.value = "";
+ }
+}
\ No newline at end of file
diff --git a/static/js/controllers/update_form_controller.js b/static/js/controllers/update_form_controller.js
new file mode 100644
index 0000000..d892ca1
--- /dev/null
+++ b/static/js/controllers/update_form_controller.js
@@ -0,0 +1,25 @@
+import { Controller } from "../vendors/stimulus.js"
+import { sendData } from "../webSocketsCli.js"
+
+export default class extends Controller {
+
+ static targets = [ "author", "text" ]
+
+ /**
+ * Update message
+ * @param {Event} event
+ * @return {void}
+ */
+ update(event) {
+ event.preventDefault();
+ const message = {
+ "action": "update message",
+ "data": {
+ "id": event.target.dataset.id,
+ "author": this.authorTarget.value,
+ "text": this.textTarget.value
+ }
+ };
+ sendData(message, myWebSocket);
+ }
+}
\ No newline at end of file
diff --git a/static/js/main.js b/static/js/main.js
new file mode 100755
index 0000000..7221350
--- /dev/null
+++ b/static/js/main.js
@@ -0,0 +1,15 @@
+import {connect, startEvents} from './webSocketsCli.js';
+import { Application } from "./vendors/stimulus.js"
+import navbarController from "./controllers/navbar.js"
+/*
+ INITIALIZATION
+*/
+
+// WebSocket connection
+connect();
+startEvents();
+
+// Stimulus
+window.Stimulus = Application.start();
+// Register all controllers
+Stimulus.register("navbar", navbarController);
\ No newline at end of file
diff --git a/static/js/vendors/stimulus.js b/static/js/vendors/stimulus.js
new file mode 100644
index 0000000..dd0f992
--- /dev/null
+++ b/static/js/vendors/stimulus.js
@@ -0,0 +1,1944 @@
+/*
+Stimulus 3.0.1
+Copyright © 2021 Basecamp, LLC
+ */
+class EventListener {
+ constructor(eventTarget, eventName, eventOptions) {
+ this.eventTarget = eventTarget;
+ this.eventName = eventName;
+ this.eventOptions = eventOptions;
+ this.unorderedBindings = new Set();
+ }
+ connect() {
+ this.eventTarget.addEventListener(this.eventName, this, this.eventOptions);
+ }
+ disconnect() {
+ this.eventTarget.removeEventListener(this.eventName, this, this.eventOptions);
+ }
+ bindingConnected(binding) {
+ this.unorderedBindings.add(binding);
+ }
+ bindingDisconnected(binding) {
+ this.unorderedBindings.delete(binding);
+ }
+ handleEvent(event) {
+ const extendedEvent = extendEvent(event);
+ for (const binding of this.bindings) {
+ if (extendedEvent.immediatePropagationStopped) {
+ break;
+ }
+ else {
+ binding.handleEvent(extendedEvent);
+ }
+ }
+ }
+ get bindings() {
+ return Array.from(this.unorderedBindings).sort((left, right) => {
+ const leftIndex = left.index, rightIndex = right.index;
+ return leftIndex < rightIndex ? -1 : leftIndex > rightIndex ? 1 : 0;
+ });
+ }
+}
+function extendEvent(event) {
+ if ("immediatePropagationStopped" in event) {
+ return event;
+ }
+ else {
+ const { stopImmediatePropagation } = event;
+ return Object.assign(event, {
+ immediatePropagationStopped: false,
+ stopImmediatePropagation() {
+ this.immediatePropagationStopped = true;
+ stopImmediatePropagation.call(this);
+ }
+ });
+ }
+}
+
+class Dispatcher {
+ constructor(application) {
+ this.application = application;
+ this.eventListenerMaps = new Map;
+ this.started = false;
+ }
+ start() {
+ if (!this.started) {
+ this.started = true;
+ this.eventListeners.forEach(eventListener => eventListener.connect());
+ }
+ }
+ stop() {
+ if (this.started) {
+ this.started = false;
+ this.eventListeners.forEach(eventListener => eventListener.disconnect());
+ }
+ }
+ get eventListeners() {
+ return Array.from(this.eventListenerMaps.values())
+ .reduce((listeners, map) => listeners.concat(Array.from(map.values())), []);
+ }
+ bindingConnected(binding) {
+ this.fetchEventListenerForBinding(binding).bindingConnected(binding);
+ }
+ bindingDisconnected(binding) {
+ this.fetchEventListenerForBinding(binding).bindingDisconnected(binding);
+ }
+ handleError(error, message, detail = {}) {
+ this.application.handleError(error, `Error ${message}`, detail);
+ }
+ fetchEventListenerForBinding(binding) {
+ const { eventTarget, eventName, eventOptions } = binding;
+ return this.fetchEventListener(eventTarget, eventName, eventOptions);
+ }
+ fetchEventListener(eventTarget, eventName, eventOptions) {
+ const eventListenerMap = this.fetchEventListenerMapForEventTarget(eventTarget);
+ const cacheKey = this.cacheKey(eventName, eventOptions);
+ let eventListener = eventListenerMap.get(cacheKey);
+ if (!eventListener) {
+ eventListener = this.createEventListener(eventTarget, eventName, eventOptions);
+ eventListenerMap.set(cacheKey, eventListener);
+ }
+ return eventListener;
+ }
+ createEventListener(eventTarget, eventName, eventOptions) {
+ const eventListener = new EventListener(eventTarget, eventName, eventOptions);
+ if (this.started) {
+ eventListener.connect();
+ }
+ return eventListener;
+ }
+ fetchEventListenerMapForEventTarget(eventTarget) {
+ let eventListenerMap = this.eventListenerMaps.get(eventTarget);
+ if (!eventListenerMap) {
+ eventListenerMap = new Map;
+ this.eventListenerMaps.set(eventTarget, eventListenerMap);
+ }
+ return eventListenerMap;
+ }
+ cacheKey(eventName, eventOptions) {
+ const parts = [eventName];
+ Object.keys(eventOptions).sort().forEach(key => {
+ parts.push(`${eventOptions[key] ? "" : "!"}${key}`);
+ });
+ return parts.join(":");
+ }
+}
+
+const descriptorPattern = /^((.+?)(@(window|document))?->)?(.+?)(#([^:]+?))(:(.+))?$/;
+function parseActionDescriptorString(descriptorString) {
+ const source = descriptorString.trim();
+ const matches = source.match(descriptorPattern) || [];
+ return {
+ eventTarget: parseEventTarget(matches[4]),
+ eventName: matches[2],
+ eventOptions: matches[9] ? parseEventOptions(matches[9]) : {},
+ identifier: matches[5],
+ methodName: matches[7]
+ };
+}
+function parseEventTarget(eventTargetName) {
+ if (eventTargetName == "window") {
+ return window;
+ }
+ else if (eventTargetName == "document") {
+ return document;
+ }
+}
+function parseEventOptions(eventOptions) {
+ return eventOptions.split(":").reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {});
+}
+function stringifyEventTarget(eventTarget) {
+ if (eventTarget == window) {
+ return "window";
+ }
+ else if (eventTarget == document) {
+ return "document";
+ }
+}
+
+function camelize(value) {
+ return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase());
+}
+function capitalize(value) {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+function dasherize(value) {
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`);
+}
+function tokenize(value) {
+ return value.match(/[^\s]+/g) || [];
+}
+
+class Action {
+ constructor(element, index, descriptor) {
+ this.element = element;
+ this.index = index;
+ this.eventTarget = descriptor.eventTarget || element;
+ this.eventName = descriptor.eventName || getDefaultEventNameForElement(element) || error("missing event name");
+ this.eventOptions = descriptor.eventOptions || {};
+ this.identifier = descriptor.identifier || error("missing identifier");
+ this.methodName = descriptor.methodName || error("missing method name");
+ }
+ static forToken(token) {
+ return new this(token.element, token.index, parseActionDescriptorString(token.content));
+ }
+ toString() {
+ const eventNameSuffix = this.eventTargetName ? `@${this.eventTargetName}` : "";
+ return `${this.eventName}${eventNameSuffix}->${this.identifier}#${this.methodName}`;
+ }
+ get params() {
+ if (this.eventTarget instanceof Element) {
+ return this.getParamsFromEventTargetAttributes(this.eventTarget);
+ }
+ else {
+ return {};
+ }
+ }
+ getParamsFromEventTargetAttributes(eventTarget) {
+ const params = {};
+ const pattern = new RegExp(`^data-${this.identifier}-(.+)-param$`);
+ const attributes = Array.from(eventTarget.attributes);
+ attributes.forEach(({ name, value }) => {
+ const match = name.match(pattern);
+ const key = match && match[1];
+ if (key) {
+ Object.assign(params, { [camelize(key)]: typecast(value) });
+ }
+ });
+ return params;
+ }
+ get eventTargetName() {
+ return stringifyEventTarget(this.eventTarget);
+ }
+}
+const defaultEventNames = {
+ "a": e => "click",
+ "button": e => "click",
+ "form": e => "submit",
+ "details": e => "toggle",
+ "input": e => e.getAttribute("type") == "submit" ? "click" : "input",
+ "select": e => "change",
+ "textarea": e => "input"
+};
+function getDefaultEventNameForElement(element) {
+ const tagName = element.tagName.toLowerCase();
+ if (tagName in defaultEventNames) {
+ return defaultEventNames[tagName](element);
+ }
+}
+function error(message) {
+ throw new Error(message);
+}
+function typecast(value) {
+ try {
+ return JSON.parse(value);
+ }
+ catch (o_O) {
+ return value;
+ }
+}
+
+class Binding {
+ constructor(context, action) {
+ this.context = context;
+ this.action = action;
+ }
+ get index() {
+ return this.action.index;
+ }
+ get eventTarget() {
+ return this.action.eventTarget;
+ }
+ get eventOptions() {
+ return this.action.eventOptions;
+ }
+ get identifier() {
+ return this.context.identifier;
+ }
+ handleEvent(event) {
+ if (this.willBeInvokedByEvent(event)) {
+ this.invokeWithEvent(event);
+ }
+ }
+ get eventName() {
+ return this.action.eventName;
+ }
+ get method() {
+ const method = this.controller[this.methodName];
+ if (typeof method == "function") {
+ return method;
+ }
+ throw new Error(`Action "${this.action}" references undefined method "${this.methodName}"`);
+ }
+ invokeWithEvent(event) {
+ const { target, currentTarget } = event;
+ try {
+ const { params } = this.action;
+ const actionEvent = Object.assign(event, { params });
+ this.method.call(this.controller, actionEvent);
+ this.context.logDebugActivity(this.methodName, { event, target, currentTarget, action: this.methodName });
+ }
+ catch (error) {
+ const { identifier, controller, element, index } = this;
+ const detail = { identifier, controller, element, index, event };
+ this.context.handleError(error, `invoking action "${this.action}"`, detail);
+ }
+ }
+ willBeInvokedByEvent(event) {
+ const eventTarget = event.target;
+ if (this.element === eventTarget) {
+ return true;
+ }
+ else if (eventTarget instanceof Element && this.element.contains(eventTarget)) {
+ return this.scope.containsElement(eventTarget);
+ }
+ else {
+ return this.scope.containsElement(this.action.element);
+ }
+ }
+ get controller() {
+ return this.context.controller;
+ }
+ get methodName() {
+ return this.action.methodName;
+ }
+ get element() {
+ return this.scope.element;
+ }
+ get scope() {
+ return this.context.scope;
+ }
+}
+
+class ElementObserver {
+ constructor(element, delegate) {
+ this.mutationObserverInit = { attributes: true, childList: true, subtree: true };
+ this.element = element;
+ this.started = false;
+ this.delegate = delegate;
+ this.elements = new Set;
+ this.mutationObserver = new MutationObserver((mutations) => this.processMutations(mutations));
+ }
+ start() {
+ if (!this.started) {
+ this.started = true;
+ this.mutationObserver.observe(this.element, this.mutationObserverInit);
+ this.refresh();
+ }
+ }
+ pause(callback) {
+ if (this.started) {
+ this.mutationObserver.disconnect();
+ this.started = false;
+ }
+ callback();
+ if (!this.started) {
+ this.mutationObserver.observe(this.element, this.mutationObserverInit);
+ this.started = true;
+ }
+ }
+ stop() {
+ if (this.started) {
+ this.mutationObserver.takeRecords();
+ this.mutationObserver.disconnect();
+ this.started = false;
+ }
+ }
+ refresh() {
+ if (this.started) {
+ const matches = new Set(this.matchElementsInTree());
+ for (const element of Array.from(this.elements)) {
+ if (!matches.has(element)) {
+ this.removeElement(element);
+ }
+ }
+ for (const element of Array.from(matches)) {
+ this.addElement(element);
+ }
+ }
+ }
+ processMutations(mutations) {
+ if (this.started) {
+ for (const mutation of mutations) {
+ this.processMutation(mutation);
+ }
+ }
+ }
+ processMutation(mutation) {
+ if (mutation.type == "attributes") {
+ this.processAttributeChange(mutation.target, mutation.attributeName);
+ }
+ else if (mutation.type == "childList") {
+ this.processRemovedNodes(mutation.removedNodes);
+ this.processAddedNodes(mutation.addedNodes);
+ }
+ }
+ processAttributeChange(node, attributeName) {
+ const element = node;
+ if (this.elements.has(element)) {
+ if (this.delegate.elementAttributeChanged && this.matchElement(element)) {
+ this.delegate.elementAttributeChanged(element, attributeName);
+ }
+ else {
+ this.removeElement(element);
+ }
+ }
+ else if (this.matchElement(element)) {
+ this.addElement(element);
+ }
+ }
+ processRemovedNodes(nodes) {
+ for (const node of Array.from(nodes)) {
+ const element = this.elementFromNode(node);
+ if (element) {
+ this.processTree(element, this.removeElement);
+ }
+ }
+ }
+ processAddedNodes(nodes) {
+ for (const node of Array.from(nodes)) {
+ const element = this.elementFromNode(node);
+ if (element && this.elementIsActive(element)) {
+ this.processTree(element, this.addElement);
+ }
+ }
+ }
+ matchElement(element) {
+ return this.delegate.matchElement(element);
+ }
+ matchElementsInTree(tree = this.element) {
+ return this.delegate.matchElementsInTree(tree);
+ }
+ processTree(tree, processor) {
+ for (const element of this.matchElementsInTree(tree)) {
+ processor.call(this, element);
+ }
+ }
+ elementFromNode(node) {
+ if (node.nodeType == Node.ELEMENT_NODE) {
+ return node;
+ }
+ }
+ elementIsActive(element) {
+ if (element.isConnected != this.element.isConnected) {
+ return false;
+ }
+ else {
+ return this.element.contains(element);
+ }
+ }
+ addElement(element) {
+ if (!this.elements.has(element)) {
+ if (this.elementIsActive(element)) {
+ this.elements.add(element);
+ if (this.delegate.elementMatched) {
+ this.delegate.elementMatched(element);
+ }
+ }
+ }
+ }
+ removeElement(element) {
+ if (this.elements.has(element)) {
+ this.elements.delete(element);
+ if (this.delegate.elementUnmatched) {
+ this.delegate.elementUnmatched(element);
+ }
+ }
+ }
+}
+
+class AttributeObserver {
+ constructor(element, attributeName, delegate) {
+ this.attributeName = attributeName;
+ this.delegate = delegate;
+ this.elementObserver = new ElementObserver(element, this);
+ }
+ get element() {
+ return this.elementObserver.element;
+ }
+ get selector() {
+ return `[${this.attributeName}]`;
+ }
+ start() {
+ this.elementObserver.start();
+ }
+ pause(callback) {
+ this.elementObserver.pause(callback);
+ }
+ stop() {
+ this.elementObserver.stop();
+ }
+ refresh() {
+ this.elementObserver.refresh();
+ }
+ get started() {
+ return this.elementObserver.started;
+ }
+ matchElement(element) {
+ return element.hasAttribute(this.attributeName);
+ }
+ matchElementsInTree(tree) {
+ const match = this.matchElement(tree) ? [tree] : [];
+ const matches = Array.from(tree.querySelectorAll(this.selector));
+ return match.concat(matches);
+ }
+ elementMatched(element) {
+ if (this.delegate.elementMatchedAttribute) {
+ this.delegate.elementMatchedAttribute(element, this.attributeName);
+ }
+ }
+ elementUnmatched(element) {
+ if (this.delegate.elementUnmatchedAttribute) {
+ this.delegate.elementUnmatchedAttribute(element, this.attributeName);
+ }
+ }
+ elementAttributeChanged(element, attributeName) {
+ if (this.delegate.elementAttributeValueChanged && this.attributeName == attributeName) {
+ this.delegate.elementAttributeValueChanged(element, attributeName);
+ }
+ }
+}
+
+class StringMapObserver {
+ constructor(element, delegate) {
+ this.element = element;
+ this.delegate = delegate;
+ this.started = false;
+ this.stringMap = new Map;
+ this.mutationObserver = new MutationObserver(mutations => this.processMutations(mutations));
+ }
+ start() {
+ if (!this.started) {
+ this.started = true;
+ this.mutationObserver.observe(this.element, { attributes: true, attributeOldValue: true });
+ this.refresh();
+ }
+ }
+ stop() {
+ if (this.started) {
+ this.mutationObserver.takeRecords();
+ this.mutationObserver.disconnect();
+ this.started = false;
+ }
+ }
+ refresh() {
+ if (this.started) {
+ for (const attributeName of this.knownAttributeNames) {
+ this.refreshAttribute(attributeName, null);
+ }
+ }
+ }
+ processMutations(mutations) {
+ if (this.started) {
+ for (const mutation of mutations) {
+ this.processMutation(mutation);
+ }
+ }
+ }
+ processMutation(mutation) {
+ const attributeName = mutation.attributeName;
+ if (attributeName) {
+ this.refreshAttribute(attributeName, mutation.oldValue);
+ }
+ }
+ refreshAttribute(attributeName, oldValue) {
+ const key = this.delegate.getStringMapKeyForAttribute(attributeName);
+ if (key != null) {
+ if (!this.stringMap.has(attributeName)) {
+ this.stringMapKeyAdded(key, attributeName);
+ }
+ const value = this.element.getAttribute(attributeName);
+ if (this.stringMap.get(attributeName) != value) {
+ this.stringMapValueChanged(value, key, oldValue);
+ }
+ if (value == null) {
+ const oldValue = this.stringMap.get(attributeName);
+ this.stringMap.delete(attributeName);
+ if (oldValue)
+ this.stringMapKeyRemoved(key, attributeName, oldValue);
+ }
+ else {
+ this.stringMap.set(attributeName, value);
+ }
+ }
+ }
+ stringMapKeyAdded(key, attributeName) {
+ if (this.delegate.stringMapKeyAdded) {
+ this.delegate.stringMapKeyAdded(key, attributeName);
+ }
+ }
+ stringMapValueChanged(value, key, oldValue) {
+ if (this.delegate.stringMapValueChanged) {
+ this.delegate.stringMapValueChanged(value, key, oldValue);
+ }
+ }
+ stringMapKeyRemoved(key, attributeName, oldValue) {
+ if (this.delegate.stringMapKeyRemoved) {
+ this.delegate.stringMapKeyRemoved(key, attributeName, oldValue);
+ }
+ }
+ get knownAttributeNames() {
+ return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames)));
+ }
+ get currentAttributeNames() {
+ return Array.from(this.element.attributes).map(attribute => attribute.name);
+ }
+ get recordedAttributeNames() {
+ return Array.from(this.stringMap.keys());
+ }
+}
+
+function add(map, key, value) {
+ fetch(map, key).add(value);
+}
+function del(map, key, value) {
+ fetch(map, key).delete(value);
+ prune(map, key);
+}
+function fetch(map, key) {
+ let values = map.get(key);
+ if (!values) {
+ values = new Set();
+ map.set(key, values);
+ }
+ return values;
+}
+function prune(map, key) {
+ const values = map.get(key);
+ if (values != null && values.size == 0) {
+ map.delete(key);
+ }
+}
+
+class Multimap {
+ constructor() {
+ this.valuesByKey = new Map();
+ }
+ get keys() {
+ return Array.from(this.valuesByKey.keys());
+ }
+ get values() {
+ const sets = Array.from(this.valuesByKey.values());
+ return sets.reduce((values, set) => values.concat(Array.from(set)), []);
+ }
+ get size() {
+ const sets = Array.from(this.valuesByKey.values());
+ return sets.reduce((size, set) => size + set.size, 0);
+ }
+ add(key, value) {
+ add(this.valuesByKey, key, value);
+ }
+ delete(key, value) {
+ del(this.valuesByKey, key, value);
+ }
+ has(key, value) {
+ const values = this.valuesByKey.get(key);
+ return values != null && values.has(value);
+ }
+ hasKey(key) {
+ return this.valuesByKey.has(key);
+ }
+ hasValue(value) {
+ const sets = Array.from(this.valuesByKey.values());
+ return sets.some(set => set.has(value));
+ }
+ getValuesForKey(key) {
+ const values = this.valuesByKey.get(key);
+ return values ? Array.from(values) : [];
+ }
+ getKeysForValue(value) {
+ return Array.from(this.valuesByKey)
+ .filter(([key, values]) => values.has(value))
+ .map(([key, values]) => key);
+ }
+}
+
+class IndexedMultimap extends Multimap {
+ constructor() {
+ super();
+ this.keysByValue = new Map;
+ }
+ get values() {
+ return Array.from(this.keysByValue.keys());
+ }
+ add(key, value) {
+ super.add(key, value);
+ add(this.keysByValue, value, key);
+ }
+ delete(key, value) {
+ super.delete(key, value);
+ del(this.keysByValue, value, key);
+ }
+ hasValue(value) {
+ return this.keysByValue.has(value);
+ }
+ getKeysForValue(value) {
+ const set = this.keysByValue.get(value);
+ return set ? Array.from(set) : [];
+ }
+}
+
+class TokenListObserver {
+ constructor(element, attributeName, delegate) {
+ this.attributeObserver = new AttributeObserver(element, attributeName, this);
+ this.delegate = delegate;
+ this.tokensByElement = new Multimap;
+ }
+ get started() {
+ return this.attributeObserver.started;
+ }
+ start() {
+ this.attributeObserver.start();
+ }
+ pause(callback) {
+ this.attributeObserver.pause(callback);
+ }
+ stop() {
+ this.attributeObserver.stop();
+ }
+ refresh() {
+ this.attributeObserver.refresh();
+ }
+ get element() {
+ return this.attributeObserver.element;
+ }
+ get attributeName() {
+ return this.attributeObserver.attributeName;
+ }
+ elementMatchedAttribute(element) {
+ this.tokensMatched(this.readTokensForElement(element));
+ }
+ elementAttributeValueChanged(element) {
+ const [unmatchedTokens, matchedTokens] = this.refreshTokensForElement(element);
+ this.tokensUnmatched(unmatchedTokens);
+ this.tokensMatched(matchedTokens);
+ }
+ elementUnmatchedAttribute(element) {
+ this.tokensUnmatched(this.tokensByElement.getValuesForKey(element));
+ }
+ tokensMatched(tokens) {
+ tokens.forEach(token => this.tokenMatched(token));
+ }
+ tokensUnmatched(tokens) {
+ tokens.forEach(token => this.tokenUnmatched(token));
+ }
+ tokenMatched(token) {
+ this.delegate.tokenMatched(token);
+ this.tokensByElement.add(token.element, token);
+ }
+ tokenUnmatched(token) {
+ this.delegate.tokenUnmatched(token);
+ this.tokensByElement.delete(token.element, token);
+ }
+ refreshTokensForElement(element) {
+ const previousTokens = this.tokensByElement.getValuesForKey(element);
+ const currentTokens = this.readTokensForElement(element);
+ const firstDifferingIndex = zip(previousTokens, currentTokens)
+ .findIndex(([previousToken, currentToken]) => !tokensAreEqual(previousToken, currentToken));
+ if (firstDifferingIndex == -1) {
+ return [[], []];
+ }
+ else {
+ return [previousTokens.slice(firstDifferingIndex), currentTokens.slice(firstDifferingIndex)];
+ }
+ }
+ readTokensForElement(element) {
+ const attributeName = this.attributeName;
+ const tokenString = element.getAttribute(attributeName) || "";
+ return parseTokenString(tokenString, element, attributeName);
+ }
+}
+function parseTokenString(tokenString, element, attributeName) {
+ return tokenString.trim().split(/\s+/).filter(content => content.length)
+ .map((content, index) => ({ element, attributeName, content, index }));
+}
+function zip(left, right) {
+ const length = Math.max(left.length, right.length);
+ return Array.from({ length }, (_, index) => [left[index], right[index]]);
+}
+function tokensAreEqual(left, right) {
+ return left && right && left.index == right.index && left.content == right.content;
+}
+
+class ValueListObserver {
+ constructor(element, attributeName, delegate) {
+ this.tokenListObserver = new TokenListObserver(element, attributeName, this);
+ this.delegate = delegate;
+ this.parseResultsByToken = new WeakMap;
+ this.valuesByTokenByElement = new WeakMap;
+ }
+ get started() {
+ return this.tokenListObserver.started;
+ }
+ start() {
+ this.tokenListObserver.start();
+ }
+ stop() {
+ this.tokenListObserver.stop();
+ }
+ refresh() {
+ this.tokenListObserver.refresh();
+ }
+ get element() {
+ return this.tokenListObserver.element;
+ }
+ get attributeName() {
+ return this.tokenListObserver.attributeName;
+ }
+ tokenMatched(token) {
+ const { element } = token;
+ const { value } = this.fetchParseResultForToken(token);
+ if (value) {
+ this.fetchValuesByTokenForElement(element).set(token, value);
+ this.delegate.elementMatchedValue(element, value);
+ }
+ }
+ tokenUnmatched(token) {
+ const { element } = token;
+ const { value } = this.fetchParseResultForToken(token);
+ if (value) {
+ this.fetchValuesByTokenForElement(element).delete(token);
+ this.delegate.elementUnmatchedValue(element, value);
+ }
+ }
+ fetchParseResultForToken(token) {
+ let parseResult = this.parseResultsByToken.get(token);
+ if (!parseResult) {
+ parseResult = this.parseToken(token);
+ this.parseResultsByToken.set(token, parseResult);
+ }
+ return parseResult;
+ }
+ fetchValuesByTokenForElement(element) {
+ let valuesByToken = this.valuesByTokenByElement.get(element);
+ if (!valuesByToken) {
+ valuesByToken = new Map;
+ this.valuesByTokenByElement.set(element, valuesByToken);
+ }
+ return valuesByToken;
+ }
+ parseToken(token) {
+ try {
+ const value = this.delegate.parseValueForToken(token);
+ return { value };
+ }
+ catch (error) {
+ return { error };
+ }
+ }
+}
+
+class BindingObserver {
+ constructor(context, delegate) {
+ this.context = context;
+ this.delegate = delegate;
+ this.bindingsByAction = new Map;
+ }
+ start() {
+ if (!this.valueListObserver) {
+ this.valueListObserver = new ValueListObserver(this.element, this.actionAttribute, this);
+ this.valueListObserver.start();
+ }
+ }
+ stop() {
+ if (this.valueListObserver) {
+ this.valueListObserver.stop();
+ delete this.valueListObserver;
+ this.disconnectAllActions();
+ }
+ }
+ get element() {
+ return this.context.element;
+ }
+ get identifier() {
+ return this.context.identifier;
+ }
+ get actionAttribute() {
+ return this.schema.actionAttribute;
+ }
+ get schema() {
+ return this.context.schema;
+ }
+ get bindings() {
+ return Array.from(this.bindingsByAction.values());
+ }
+ connectAction(action) {
+ const binding = new Binding(this.context, action);
+ this.bindingsByAction.set(action, binding);
+ this.delegate.bindingConnected(binding);
+ }
+ disconnectAction(action) {
+ const binding = this.bindingsByAction.get(action);
+ if (binding) {
+ this.bindingsByAction.delete(action);
+ this.delegate.bindingDisconnected(binding);
+ }
+ }
+ disconnectAllActions() {
+ this.bindings.forEach(binding => this.delegate.bindingDisconnected(binding));
+ this.bindingsByAction.clear();
+ }
+ parseValueForToken(token) {
+ const action = Action.forToken(token);
+ if (action.identifier == this.identifier) {
+ return action;
+ }
+ }
+ elementMatchedValue(element, action) {
+ this.connectAction(action);
+ }
+ elementUnmatchedValue(element, action) {
+ this.disconnectAction(action);
+ }
+}
+
+class ValueObserver {
+ constructor(context, receiver) {
+ this.context = context;
+ this.receiver = receiver;
+ this.stringMapObserver = new StringMapObserver(this.element, this);
+ this.valueDescriptorMap = this.controller.valueDescriptorMap;
+ this.invokeChangedCallbacksForDefaultValues();
+ }
+ start() {
+ this.stringMapObserver.start();
+ }
+ stop() {
+ this.stringMapObserver.stop();
+ }
+ get element() {
+ return this.context.element;
+ }
+ get controller() {
+ return this.context.controller;
+ }
+ getStringMapKeyForAttribute(attributeName) {
+ if (attributeName in this.valueDescriptorMap) {
+ return this.valueDescriptorMap[attributeName].name;
+ }
+ }
+ stringMapKeyAdded(key, attributeName) {
+ const descriptor = this.valueDescriptorMap[attributeName];
+ if (!this.hasValue(key)) {
+ this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), descriptor.writer(descriptor.defaultValue));
+ }
+ }
+ stringMapValueChanged(value, name, oldValue) {
+ const descriptor = this.valueDescriptorNameMap[name];
+ if (value === null)
+ return;
+ if (oldValue === null) {
+ oldValue = descriptor.writer(descriptor.defaultValue);
+ }
+ this.invokeChangedCallback(name, value, oldValue);
+ }
+ stringMapKeyRemoved(key, attributeName, oldValue) {
+ const descriptor = this.valueDescriptorNameMap[key];
+ if (this.hasValue(key)) {
+ this.invokeChangedCallback(key, descriptor.writer(this.receiver[key]), oldValue);
+ }
+ else {
+ this.invokeChangedCallback(key, descriptor.writer(descriptor.defaultValue), oldValue);
+ }
+ }
+ invokeChangedCallbacksForDefaultValues() {
+ for (const { key, name, defaultValue, writer } of this.valueDescriptors) {
+ if (defaultValue != undefined && !this.controller.data.has(key)) {
+ this.invokeChangedCallback(name, writer(defaultValue), undefined);
+ }
+ }
+ }
+ invokeChangedCallback(name, rawValue, rawOldValue) {
+ const changedMethodName = `${name}Changed`;
+ const changedMethod = this.receiver[changedMethodName];
+ if (typeof changedMethod == "function") {
+ const descriptor = this.valueDescriptorNameMap[name];
+ const value = descriptor.reader(rawValue);
+ let oldValue = rawOldValue;
+ if (rawOldValue) {
+ oldValue = descriptor.reader(rawOldValue);
+ }
+ changedMethod.call(this.receiver, value, oldValue);
+ }
+ }
+ get valueDescriptors() {
+ const { valueDescriptorMap } = this;
+ return Object.keys(valueDescriptorMap).map(key => valueDescriptorMap[key]);
+ }
+ get valueDescriptorNameMap() {
+ const descriptors = {};
+ Object.keys(this.valueDescriptorMap).forEach(key => {
+ const descriptor = this.valueDescriptorMap[key];
+ descriptors[descriptor.name] = descriptor;
+ });
+ return descriptors;
+ }
+ hasValue(attributeName) {
+ const descriptor = this.valueDescriptorNameMap[attributeName];
+ const hasMethodName = `has${capitalize(descriptor.name)}`;
+ return this.receiver[hasMethodName];
+ }
+}
+
+class TargetObserver {
+ constructor(context, delegate) {
+ this.context = context;
+ this.delegate = delegate;
+ this.targetsByName = new Multimap;
+ }
+ start() {
+ if (!this.tokenListObserver) {
+ this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this);
+ this.tokenListObserver.start();
+ }
+ }
+ stop() {
+ if (this.tokenListObserver) {
+ this.disconnectAllTargets();
+ this.tokenListObserver.stop();
+ delete this.tokenListObserver;
+ }
+ }
+ tokenMatched({ element, content: name }) {
+ if (this.scope.containsElement(element)) {
+ this.connectTarget(element, name);
+ }
+ }
+ tokenUnmatched({ element, content: name }) {
+ this.disconnectTarget(element, name);
+ }
+ connectTarget(element, name) {
+ var _a;
+ if (!this.targetsByName.has(name, element)) {
+ this.targetsByName.add(name, element);
+ (_a = this.tokenListObserver) === null || _a === void 0 ? void 0 : _a.pause(() => this.delegate.targetConnected(element, name));
+ }
+ }
+ disconnectTarget(element, name) {
+ var _a;
+ if (this.targetsByName.has(name, element)) {
+ this.targetsByName.delete(name, element);
+ (_a = this.tokenListObserver) === null || _a === void 0 ? void 0 : _a.pause(() => this.delegate.targetDisconnected(element, name));
+ }
+ }
+ disconnectAllTargets() {
+ for (const name of this.targetsByName.keys) {
+ for (const element of this.targetsByName.getValuesForKey(name)) {
+ this.disconnectTarget(element, name);
+ }
+ }
+ }
+ get attributeName() {
+ return `data-${this.context.identifier}-target`;
+ }
+ get element() {
+ return this.context.element;
+ }
+ get scope() {
+ return this.context.scope;
+ }
+}
+
+class Context {
+ constructor(module, scope) {
+ this.logDebugActivity = (functionName, detail = {}) => {
+ const { identifier, controller, element } = this;
+ detail = Object.assign({ identifier, controller, element }, detail);
+ this.application.logDebugActivity(this.identifier, functionName, detail);
+ };
+ this.module = module;
+ this.scope = scope;
+ this.controller = new module.controllerConstructor(this);
+ this.bindingObserver = new BindingObserver(this, this.dispatcher);
+ this.valueObserver = new ValueObserver(this, this.controller);
+ this.targetObserver = new TargetObserver(this, this);
+ try {
+ this.controller.initialize();
+ this.logDebugActivity("initialize");
+ }
+ catch (error) {
+ this.handleError(error, "initializing controller");
+ }
+ }
+ connect() {
+ this.bindingObserver.start();
+ this.valueObserver.start();
+ this.targetObserver.start();
+ try {
+ this.controller.connect();
+ this.logDebugActivity("connect");
+ }
+ catch (error) {
+ this.handleError(error, "connecting controller");
+ }
+ }
+ disconnect() {
+ try {
+ this.controller.disconnect();
+ this.logDebugActivity("disconnect");
+ }
+ catch (error) {
+ this.handleError(error, "disconnecting controller");
+ }
+ this.targetObserver.stop();
+ this.valueObserver.stop();
+ this.bindingObserver.stop();
+ }
+ get application() {
+ return this.module.application;
+ }
+ get identifier() {
+ return this.module.identifier;
+ }
+ get schema() {
+ return this.application.schema;
+ }
+ get dispatcher() {
+ return this.application.dispatcher;
+ }
+ get element() {
+ return this.scope.element;
+ }
+ get parentElement() {
+ return this.element.parentElement;
+ }
+ handleError(error, message, detail = {}) {
+ const { identifier, controller, element } = this;
+ detail = Object.assign({ identifier, controller, element }, detail);
+ this.application.handleError(error, `Error ${message}`, detail);
+ }
+ targetConnected(element, name) {
+ this.invokeControllerMethod(`${name}TargetConnected`, element);
+ }
+ targetDisconnected(element, name) {
+ this.invokeControllerMethod(`${name}TargetDisconnected`, element);
+ }
+ invokeControllerMethod(methodName, ...args) {
+ const controller = this.controller;
+ if (typeof controller[methodName] == "function") {
+ controller[methodName](...args);
+ }
+ }
+}
+
+function readInheritableStaticArrayValues(constructor, propertyName) {
+ const ancestors = getAncestorsForConstructor(constructor);
+ return Array.from(ancestors.reduce((values, constructor) => {
+ getOwnStaticArrayValues(constructor, propertyName).forEach(name => values.add(name));
+ return values;
+ }, new Set));
+}
+function readInheritableStaticObjectPairs(constructor, propertyName) {
+ const ancestors = getAncestorsForConstructor(constructor);
+ return ancestors.reduce((pairs, constructor) => {
+ pairs.push(...getOwnStaticObjectPairs(constructor, propertyName));
+ return pairs;
+ }, []);
+}
+function getAncestorsForConstructor(constructor) {
+ const ancestors = [];
+ while (constructor) {
+ ancestors.push(constructor);
+ constructor = Object.getPrototypeOf(constructor);
+ }
+ return ancestors.reverse();
+}
+function getOwnStaticArrayValues(constructor, propertyName) {
+ const definition = constructor[propertyName];
+ return Array.isArray(definition) ? definition : [];
+}
+function getOwnStaticObjectPairs(constructor, propertyName) {
+ const definition = constructor[propertyName];
+ return definition ? Object.keys(definition).map(key => [key, definition[key]]) : [];
+}
+
+function bless(constructor) {
+ return shadow(constructor, getBlessedProperties(constructor));
+}
+function shadow(constructor, properties) {
+ const shadowConstructor = extend(constructor);
+ const shadowProperties = getShadowProperties(constructor.prototype, properties);
+ Object.defineProperties(shadowConstructor.prototype, shadowProperties);
+ return shadowConstructor;
+}
+function getBlessedProperties(constructor) {
+ const blessings = readInheritableStaticArrayValues(constructor, "blessings");
+ return blessings.reduce((blessedProperties, blessing) => {
+ const properties = blessing(constructor);
+ for (const key in properties) {
+ const descriptor = blessedProperties[key] || {};
+ blessedProperties[key] = Object.assign(descriptor, properties[key]);
+ }
+ return blessedProperties;
+ }, {});
+}
+function getShadowProperties(prototype, properties) {
+ return getOwnKeys(properties).reduce((shadowProperties, key) => {
+ const descriptor = getShadowedDescriptor(prototype, properties, key);
+ if (descriptor) {
+ Object.assign(shadowProperties, { [key]: descriptor });
+ }
+ return shadowProperties;
+ }, {});
+}
+function getShadowedDescriptor(prototype, properties, key) {
+ const shadowingDescriptor = Object.getOwnPropertyDescriptor(prototype, key);
+ const shadowedByValue = shadowingDescriptor && "value" in shadowingDescriptor;
+ if (!shadowedByValue) {
+ const descriptor = Object.getOwnPropertyDescriptor(properties, key).value;
+ if (shadowingDescriptor) {
+ descriptor.get = shadowingDescriptor.get || descriptor.get;
+ descriptor.set = shadowingDescriptor.set || descriptor.set;
+ }
+ return descriptor;
+ }
+}
+const getOwnKeys = (() => {
+ if (typeof Object.getOwnPropertySymbols == "function") {
+ return (object) => [
+ ...Object.getOwnPropertyNames(object),
+ ...Object.getOwnPropertySymbols(object)
+ ];
+ }
+ else {
+ return Object.getOwnPropertyNames;
+ }
+})();
+const extend = (() => {
+ function extendWithReflect(constructor) {
+ function extended() {
+ return Reflect.construct(constructor, arguments, new.target);
+ }
+ extended.prototype = Object.create(constructor.prototype, {
+ constructor: { value: extended }
+ });
+ Reflect.setPrototypeOf(extended, constructor);
+ return extended;
+ }
+ function testReflectExtension() {
+ const a = function () { this.a.call(this); };
+ const b = extendWithReflect(a);
+ b.prototype.a = function () { };
+ return new b;
+ }
+ try {
+ testReflectExtension();
+ return extendWithReflect;
+ }
+ catch (error) {
+ return (constructor) => class extended extends constructor {
+ };
+ }
+})();
+
+function blessDefinition(definition) {
+ return {
+ identifier: definition.identifier,
+ controllerConstructor: bless(definition.controllerConstructor)
+ };
+}
+
+class Module {
+ constructor(application, definition) {
+ this.application = application;
+ this.definition = blessDefinition(definition);
+ this.contextsByScope = new WeakMap;
+ this.connectedContexts = new Set;
+ }
+ get identifier() {
+ return this.definition.identifier;
+ }
+ get controllerConstructor() {
+ return this.definition.controllerConstructor;
+ }
+ get contexts() {
+ return Array.from(this.connectedContexts);
+ }
+ connectContextForScope(scope) {
+ const context = this.fetchContextForScope(scope);
+ this.connectedContexts.add(context);
+ context.connect();
+ }
+ disconnectContextForScope(scope) {
+ const context = this.contextsByScope.get(scope);
+ if (context) {
+ this.connectedContexts.delete(context);
+ context.disconnect();
+ }
+ }
+ fetchContextForScope(scope) {
+ let context = this.contextsByScope.get(scope);
+ if (!context) {
+ context = new Context(this, scope);
+ this.contextsByScope.set(scope, context);
+ }
+ return context;
+ }
+}
+
+class ClassMap {
+ constructor(scope) {
+ this.scope = scope;
+ }
+ has(name) {
+ return this.data.has(this.getDataKey(name));
+ }
+ get(name) {
+ return this.getAll(name)[0];
+ }
+ getAll(name) {
+ const tokenString = this.data.get(this.getDataKey(name)) || "";
+ return tokenize(tokenString);
+ }
+ getAttributeName(name) {
+ return this.data.getAttributeNameForKey(this.getDataKey(name));
+ }
+ getDataKey(name) {
+ return `${name}-class`;
+ }
+ get data() {
+ return this.scope.data;
+ }
+}
+
+class DataMap {
+ constructor(scope) {
+ this.scope = scope;
+ }
+ get element() {
+ return this.scope.element;
+ }
+ get identifier() {
+ return this.scope.identifier;
+ }
+ get(key) {
+ const name = this.getAttributeNameForKey(key);
+ return this.element.getAttribute(name);
+ }
+ set(key, value) {
+ const name = this.getAttributeNameForKey(key);
+ this.element.setAttribute(name, value);
+ return this.get(key);
+ }
+ has(key) {
+ const name = this.getAttributeNameForKey(key);
+ return this.element.hasAttribute(name);
+ }
+ delete(key) {
+ if (this.has(key)) {
+ const name = this.getAttributeNameForKey(key);
+ this.element.removeAttribute(name);
+ return true;
+ }
+ else {
+ return false;
+ }
+ }
+ getAttributeNameForKey(key) {
+ return `data-${this.identifier}-${dasherize(key)}`;
+ }
+}
+
+class Guide {
+ constructor(logger) {
+ this.warnedKeysByObject = new WeakMap;
+ this.logger = logger;
+ }
+ warn(object, key, message) {
+ let warnedKeys = this.warnedKeysByObject.get(object);
+ if (!warnedKeys) {
+ warnedKeys = new Set;
+ this.warnedKeysByObject.set(object, warnedKeys);
+ }
+ if (!warnedKeys.has(key)) {
+ warnedKeys.add(key);
+ this.logger.warn(message, object);
+ }
+ }
+}
+
+function attributeValueContainsToken(attributeName, token) {
+ return `[${attributeName}~="${token}"]`;
+}
+
+class TargetSet {
+ constructor(scope) {
+ this.scope = scope;
+ }
+ get element() {
+ return this.scope.element;
+ }
+ get identifier() {
+ return this.scope.identifier;
+ }
+ get schema() {
+ return this.scope.schema;
+ }
+ has(targetName) {
+ return this.find(targetName) != null;
+ }
+ find(...targetNames) {
+ return targetNames.reduce((target, targetName) => target
+ || this.findTarget(targetName)
+ || this.findLegacyTarget(targetName), undefined);
+ }
+ findAll(...targetNames) {
+ return targetNames.reduce((targets, targetName) => [
+ ...targets,
+ ...this.findAllTargets(targetName),
+ ...this.findAllLegacyTargets(targetName)
+ ], []);
+ }
+ findTarget(targetName) {
+ const selector = this.getSelectorForTargetName(targetName);
+ return this.scope.findElement(selector);
+ }
+ findAllTargets(targetName) {
+ const selector = this.getSelectorForTargetName(targetName);
+ return this.scope.findAllElements(selector);
+ }
+ getSelectorForTargetName(targetName) {
+ const attributeName = this.schema.targetAttributeForScope(this.identifier);
+ return attributeValueContainsToken(attributeName, targetName);
+ }
+ findLegacyTarget(targetName) {
+ const selector = this.getLegacySelectorForTargetName(targetName);
+ return this.deprecate(this.scope.findElement(selector), targetName);
+ }
+ findAllLegacyTargets(targetName) {
+ const selector = this.getLegacySelectorForTargetName(targetName);
+ return this.scope.findAllElements(selector).map(element => this.deprecate(element, targetName));
+ }
+ getLegacySelectorForTargetName(targetName) {
+ const targetDescriptor = `${this.identifier}.${targetName}`;
+ return attributeValueContainsToken(this.schema.targetAttribute, targetDescriptor);
+ }
+ deprecate(element, targetName) {
+ if (element) {
+ const { identifier } = this;
+ const attributeName = this.schema.targetAttribute;
+ const revisedAttributeName = this.schema.targetAttributeForScope(identifier);
+ this.guide.warn(element, `target:${targetName}`, `Please replace ${attributeName}="${identifier}.${targetName}" with ${revisedAttributeName}="${targetName}". ` +
+ `The ${attributeName} attribute is deprecated and will be removed in a future version of Stimulus.`);
+ }
+ return element;
+ }
+ get guide() {
+ return this.scope.guide;
+ }
+}
+
+class Scope {
+ constructor(schema, element, identifier, logger) {
+ this.targets = new TargetSet(this);
+ this.classes = new ClassMap(this);
+ this.data = new DataMap(this);
+ this.containsElement = (element) => {
+ return element.closest(this.controllerSelector) === this.element;
+ };
+ this.schema = schema;
+ this.element = element;
+ this.identifier = identifier;
+ this.guide = new Guide(logger);
+ }
+ findElement(selector) {
+ return this.element.matches(selector)
+ ? this.element
+ : this.queryElements(selector).find(this.containsElement);
+ }
+ findAllElements(selector) {
+ return [
+ ...this.element.matches(selector) ? [this.element] : [],
+ ...this.queryElements(selector).filter(this.containsElement)
+ ];
+ }
+ queryElements(selector) {
+ return Array.from(this.element.querySelectorAll(selector));
+ }
+ get controllerSelector() {
+ return attributeValueContainsToken(this.schema.controllerAttribute, this.identifier);
+ }
+}
+
+class ScopeObserver {
+ constructor(element, schema, delegate) {
+ this.element = element;
+ this.schema = schema;
+ this.delegate = delegate;
+ this.valueListObserver = new ValueListObserver(this.element, this.controllerAttribute, this);
+ this.scopesByIdentifierByElement = new WeakMap;
+ this.scopeReferenceCounts = new WeakMap;
+ }
+ start() {
+ this.valueListObserver.start();
+ }
+ stop() {
+ this.valueListObserver.stop();
+ }
+ get controllerAttribute() {
+ return this.schema.controllerAttribute;
+ }
+ parseValueForToken(token) {
+ const { element, content: identifier } = token;
+ const scopesByIdentifier = this.fetchScopesByIdentifierForElement(element);
+ let scope = scopesByIdentifier.get(identifier);
+ if (!scope) {
+ scope = this.delegate.createScopeForElementAndIdentifier(element, identifier);
+ scopesByIdentifier.set(identifier, scope);
+ }
+ return scope;
+ }
+ elementMatchedValue(element, value) {
+ const referenceCount = (this.scopeReferenceCounts.get(value) || 0) + 1;
+ this.scopeReferenceCounts.set(value, referenceCount);
+ if (referenceCount == 1) {
+ this.delegate.scopeConnected(value);
+ }
+ }
+ elementUnmatchedValue(element, value) {
+ const referenceCount = this.scopeReferenceCounts.get(value);
+ if (referenceCount) {
+ this.scopeReferenceCounts.set(value, referenceCount - 1);
+ if (referenceCount == 1) {
+ this.delegate.scopeDisconnected(value);
+ }
+ }
+ }
+ fetchScopesByIdentifierForElement(element) {
+ let scopesByIdentifier = this.scopesByIdentifierByElement.get(element);
+ if (!scopesByIdentifier) {
+ scopesByIdentifier = new Map;
+ this.scopesByIdentifierByElement.set(element, scopesByIdentifier);
+ }
+ return scopesByIdentifier;
+ }
+}
+
+class Router {
+ constructor(application) {
+ this.application = application;
+ this.scopeObserver = new ScopeObserver(this.element, this.schema, this);
+ this.scopesByIdentifier = new Multimap;
+ this.modulesByIdentifier = new Map;
+ }
+ get element() {
+ return this.application.element;
+ }
+ get schema() {
+ return this.application.schema;
+ }
+ get logger() {
+ return this.application.logger;
+ }
+ get controllerAttribute() {
+ return this.schema.controllerAttribute;
+ }
+ get modules() {
+ return Array.from(this.modulesByIdentifier.values());
+ }
+ get contexts() {
+ return this.modules.reduce((contexts, module) => contexts.concat(module.contexts), []);
+ }
+ start() {
+ this.scopeObserver.start();
+ }
+ stop() {
+ this.scopeObserver.stop();
+ }
+ loadDefinition(definition) {
+ this.unloadIdentifier(definition.identifier);
+ const module = new Module(this.application, definition);
+ this.connectModule(module);
+ }
+ unloadIdentifier(identifier) {
+ const module = this.modulesByIdentifier.get(identifier);
+ if (module) {
+ this.disconnectModule(module);
+ }
+ }
+ getContextForElementAndIdentifier(element, identifier) {
+ const module = this.modulesByIdentifier.get(identifier);
+ if (module) {
+ return module.contexts.find(context => context.element == element);
+ }
+ }
+ handleError(error, message, detail) {
+ this.application.handleError(error, message, detail);
+ }
+ createScopeForElementAndIdentifier(element, identifier) {
+ return new Scope(this.schema, element, identifier, this.logger);
+ }
+ scopeConnected(scope) {
+ this.scopesByIdentifier.add(scope.identifier, scope);
+ const module = this.modulesByIdentifier.get(scope.identifier);
+ if (module) {
+ module.connectContextForScope(scope);
+ }
+ }
+ scopeDisconnected(scope) {
+ this.scopesByIdentifier.delete(scope.identifier, scope);
+ const module = this.modulesByIdentifier.get(scope.identifier);
+ if (module) {
+ module.disconnectContextForScope(scope);
+ }
+ }
+ connectModule(module) {
+ this.modulesByIdentifier.set(module.identifier, module);
+ const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier);
+ scopes.forEach(scope => module.connectContextForScope(scope));
+ }
+ disconnectModule(module) {
+ this.modulesByIdentifier.delete(module.identifier);
+ const scopes = this.scopesByIdentifier.getValuesForKey(module.identifier);
+ scopes.forEach(scope => module.disconnectContextForScope(scope));
+ }
+}
+
+const defaultSchema = {
+ controllerAttribute: "data-controller",
+ actionAttribute: "data-action",
+ targetAttribute: "data-target",
+ targetAttributeForScope: identifier => `data-${identifier}-target`
+};
+
+class Application {
+ constructor(element = document.documentElement, schema = defaultSchema) {
+ this.logger = console;
+ this.debug = false;
+ this.logDebugActivity = (identifier, functionName, detail = {}) => {
+ if (this.debug) {
+ this.logFormattedMessage(identifier, functionName, detail);
+ }
+ };
+ this.element = element;
+ this.schema = schema;
+ this.dispatcher = new Dispatcher(this);
+ this.router = new Router(this);
+ }
+ static start(element, schema) {
+ const application = new Application(element, schema);
+ application.start();
+ return application;
+ }
+ async start() {
+ await domReady();
+ this.logDebugActivity("application", "starting");
+ this.dispatcher.start();
+ this.router.start();
+ this.logDebugActivity("application", "start");
+ }
+ stop() {
+ this.logDebugActivity("application", "stopping");
+ this.dispatcher.stop();
+ this.router.stop();
+ this.logDebugActivity("application", "stop");
+ }
+ register(identifier, controllerConstructor) {
+ if (controllerConstructor.shouldLoad) {
+ this.load({ identifier, controllerConstructor });
+ }
+ }
+ load(head, ...rest) {
+ const definitions = Array.isArray(head) ? head : [head, ...rest];
+ definitions.forEach(definition => this.router.loadDefinition(definition));
+ }
+ unload(head, ...rest) {
+ const identifiers = Array.isArray(head) ? head : [head, ...rest];
+ identifiers.forEach(identifier => this.router.unloadIdentifier(identifier));
+ }
+ get controllers() {
+ return this.router.contexts.map(context => context.controller);
+ }
+ getControllerForElementAndIdentifier(element, identifier) {
+ const context = this.router.getContextForElementAndIdentifier(element, identifier);
+ return context ? context.controller : null;
+ }
+ handleError(error, message, detail) {
+ var _a;
+ this.logger.error(`%s\n\n%o\n\n%o`, message, error, detail);
+ (_a = window.onerror) === null || _a === void 0 ? void 0 : _a.call(window, message, "", 0, 0, error);
+ }
+ logFormattedMessage(identifier, functionName, detail = {}) {
+ detail = Object.assign({ application: this }, detail);
+ this.logger.groupCollapsed(`${identifier} #${functionName}`);
+ this.logger.log("details:", Object.assign({}, detail));
+ this.logger.groupEnd();
+ }
+}
+function domReady() {
+ return new Promise(resolve => {
+ if (document.readyState == "loading") {
+ document.addEventListener("DOMContentLoaded", () => resolve());
+ }
+ else {
+ resolve();
+ }
+ });
+}
+
+function ClassPropertiesBlessing(constructor) {
+ const classes = readInheritableStaticArrayValues(constructor, "classes");
+ return classes.reduce((properties, classDefinition) => {
+ return Object.assign(properties, propertiesForClassDefinition(classDefinition));
+ }, {});
+}
+function propertiesForClassDefinition(key) {
+ return {
+ [`${key}Class`]: {
+ get() {
+ const { classes } = this;
+ if (classes.has(key)) {
+ return classes.get(key);
+ }
+ else {
+ const attribute = classes.getAttributeName(key);
+ throw new Error(`Missing attribute "${attribute}"`);
+ }
+ }
+ },
+ [`${key}Classes`]: {
+ get() {
+ return this.classes.getAll(key);
+ }
+ },
+ [`has${capitalize(key)}Class`]: {
+ get() {
+ return this.classes.has(key);
+ }
+ }
+ };
+}
+
+function TargetPropertiesBlessing(constructor) {
+ const targets = readInheritableStaticArrayValues(constructor, "targets");
+ return targets.reduce((properties, targetDefinition) => {
+ return Object.assign(properties, propertiesForTargetDefinition(targetDefinition));
+ }, {});
+}
+function propertiesForTargetDefinition(name) {
+ return {
+ [`${name}Target`]: {
+ get() {
+ const target = this.targets.find(name);
+ if (target) {
+ return target;
+ }
+ else {
+ throw new Error(`Missing target element "${name}" for "${this.identifier}" controller`);
+ }
+ }
+ },
+ [`${name}Targets`]: {
+ get() {
+ return this.targets.findAll(name);
+ }
+ },
+ [`has${capitalize(name)}Target`]: {
+ get() {
+ return this.targets.has(name);
+ }
+ }
+ };
+}
+
+function ValuePropertiesBlessing(constructor) {
+ const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values");
+ const propertyDescriptorMap = {
+ valueDescriptorMap: {
+ get() {
+ return valueDefinitionPairs.reduce((result, valueDefinitionPair) => {
+ const valueDescriptor = parseValueDefinitionPair(valueDefinitionPair);
+ const attributeName = this.data.getAttributeNameForKey(valueDescriptor.key);
+ return Object.assign(result, { [attributeName]: valueDescriptor });
+ }, {});
+ }
+ }
+ };
+ return valueDefinitionPairs.reduce((properties, valueDefinitionPair) => {
+ return Object.assign(properties, propertiesForValueDefinitionPair(valueDefinitionPair));
+ }, propertyDescriptorMap);
+}
+function propertiesForValueDefinitionPair(valueDefinitionPair) {
+ const definition = parseValueDefinitionPair(valueDefinitionPair);
+ const { key, name, reader: read, writer: write } = definition;
+ return {
+ [name]: {
+ get() {
+ const value = this.data.get(key);
+ if (value !== null) {
+ return read(value);
+ }
+ else {
+ return definition.defaultValue;
+ }
+ },
+ set(value) {
+ if (value === undefined) {
+ this.data.delete(key);
+ }
+ else {
+ this.data.set(key, write(value));
+ }
+ }
+ },
+ [`has${capitalize(name)}`]: {
+ get() {
+ return this.data.has(key) || definition.hasCustomDefaultValue;
+ }
+ }
+ };
+}
+function parseValueDefinitionPair([token, typeDefinition]) {
+ return valueDescriptorForTokenAndTypeDefinition(token, typeDefinition);
+}
+function parseValueTypeConstant(constant) {
+ switch (constant) {
+ case Array: return "array";
+ case Boolean: return "boolean";
+ case Number: return "number";
+ case Object: return "object";
+ case String: return "string";
+ }
+}
+function parseValueTypeDefault(defaultValue) {
+ switch (typeof defaultValue) {
+ case "boolean": return "boolean";
+ case "number": return "number";
+ case "string": return "string";
+ }
+ if (Array.isArray(defaultValue))
+ return "array";
+ if (Object.prototype.toString.call(defaultValue) === "[object Object]")
+ return "object";
+}
+function parseValueTypeObject(typeObject) {
+ const typeFromObject = parseValueTypeConstant(typeObject.type);
+ if (typeFromObject) {
+ const defaultValueType = parseValueTypeDefault(typeObject.default);
+ if (typeFromObject !== defaultValueType) {
+ throw new Error(`Type "${typeFromObject}" must match the type of the default value. Given default value: "${typeObject.default}" as "${defaultValueType}"`);
+ }
+ return typeFromObject;
+ }
+}
+function parseValueTypeDefinition(typeDefinition) {
+ const typeFromObject = parseValueTypeObject(typeDefinition);
+ const typeFromDefaultValue = parseValueTypeDefault(typeDefinition);
+ const typeFromConstant = parseValueTypeConstant(typeDefinition);
+ const type = typeFromObject || typeFromDefaultValue || typeFromConstant;
+ if (type)
+ return type;
+ throw new Error(`Unknown value type "${typeDefinition}"`);
+}
+function defaultValueForDefinition(typeDefinition) {
+ const constant = parseValueTypeConstant(typeDefinition);
+ if (constant)
+ return defaultValuesByType[constant];
+ const defaultValue = typeDefinition.default;
+ if (defaultValue !== undefined)
+ return defaultValue;
+ return typeDefinition;
+}
+function valueDescriptorForTokenAndTypeDefinition(token, typeDefinition) {
+ const key = `${dasherize(token)}-value`;
+ const type = parseValueTypeDefinition(typeDefinition);
+ return {
+ type,
+ key,
+ name: camelize(key),
+ get defaultValue() { return defaultValueForDefinition(typeDefinition); },
+ get hasCustomDefaultValue() { return parseValueTypeDefault(typeDefinition) !== undefined; },
+ reader: readers[type],
+ writer: writers[type] || writers.default
+ };
+}
+const defaultValuesByType = {
+ get array() { return []; },
+ boolean: false,
+ number: 0,
+ get object() { return {}; },
+ string: ""
+};
+const readers = {
+ array(value) {
+ const array = JSON.parse(value);
+ if (!Array.isArray(array)) {
+ throw new TypeError("Expected array");
+ }
+ return array;
+ },
+ boolean(value) {
+ return !(value == "0" || value == "false");
+ },
+ number(value) {
+ return Number(value);
+ },
+ object(value) {
+ const object = JSON.parse(value);
+ if (object === null || typeof object != "object" || Array.isArray(object)) {
+ throw new TypeError("Expected object");
+ }
+ return object;
+ },
+ string(value) {
+ return value;
+ }
+};
+const writers = {
+ default: writeString,
+ array: writeJSON,
+ object: writeJSON
+};
+function writeJSON(value) {
+ return JSON.stringify(value);
+}
+function writeString(value) {
+ return `${value}`;
+}
+
+class Controller {
+ constructor(context) {
+ this.context = context;
+ }
+ static get shouldLoad() {
+ return true;
+ }
+ get application() {
+ return this.context.application;
+ }
+ get scope() {
+ return this.context.scope;
+ }
+ get element() {
+ return this.scope.element;
+ }
+ get identifier() {
+ return this.scope.identifier;
+ }
+ get targets() {
+ return this.scope.targets;
+ }
+ get classes() {
+ return this.scope.classes;
+ }
+ get data() {
+ return this.scope.data;
+ }
+ initialize() {
+ }
+ connect() {
+ }
+ disconnect() {
+ }
+ dispatch(eventName, { target = this.element, detail = {}, prefix = this.identifier, bubbles = true, cancelable = true } = {}) {
+ const type = prefix ? `${prefix}:${eventName}` : eventName;
+ const event = new CustomEvent(type, { detail, bubbles, cancelable });
+ target.dispatchEvent(event);
+ return event;
+ }
+}
+Controller.blessings = [ClassPropertiesBlessing, TargetPropertiesBlessing, ValuePropertiesBlessing];
+Controller.targets = [];
+Controller.values = {};
+
+export { Application, AttributeObserver, Context, Controller, ElementObserver, IndexedMultimap, Multimap, StringMapObserver, TokenListObserver, ValueListObserver, add, defaultSchema, del, fetch, prune };
diff --git a/static/js/webSocketsCli.js b/static/js/webSocketsCli.js
new file mode 100644
index 0000000..3546fe1
--- /dev/null
+++ b/static/js/webSocketsCli.js
@@ -0,0 +1,50 @@
+/*
+ FUNCTIONS
+*/
+
+/**
+ * Connect to WebSockets server (SocialNetworkConsumer)
+ * @param {string} url - WebSockets server url
+ * @return {WebSocket}
+ */
+export function connect(url=`${document.body.dataset.scheme === 'http' ? 'ws' : 'wss'}://${ document.body.dataset.host }/ws/social-network/`) {
+ window.myWebSocket = new WebSocket(url);
+ return window.myWebSocket;
+}
+
+/**
+ * Send data to WebSockets server
+ * @param {string} message
+ * @param {WebSocket} webSocket
+ * @return {void}
+ */
+export function sendData(message, webSocket) {
+ webSocket.send(JSON.stringify(message));
+}
+
+/*
+ EVENTS
+*/
+
+/**
+ * On WebSockets server connection
+ * @param {WebSocket} webSocket
+ * @return {void}
+ */
+export function startEvents(webSocket=window.myWebSocket) {
+ // Event when a new message is received by WebSockets
+ webSocket.addEventListener("message", (event) => {
+ // Parse the data received
+ const data = JSON.parse(event.data);
+ // Renders the HTML received from the Consumer
+ const newFragment = document.createRange().createContextualFragment(data.html);
+ const target = document.querySelector(data.selector);
+ if (data.append) {
+ target.appendChild(newFragment);
+ } else {
+ target.replaceChildren(newFragment);
+ }
+ // Update URL
+ history.pushState({}, '', data.url)
+ });
+}
\ No newline at end of file