diff --git a/.gitignore b/.gitignore index 6b65c096..19b0ad29 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ *.pot *.pyc __pycache__ -db.sqlite3 +*.sqlite3 media staticfiles @@ -146,4 +146,4 @@ GitHub.sublime-settings node_modules/ # Domains -deploy/letsencrypt \ No newline at end of file +deploy/letsencrypt diff --git a/app/admin_styled/static/admin/css/changelists.css b/app/admin_styled/static/admin/css/changelists.css new file mode 100644 index 00000000..5fa54963 --- /dev/null +++ b/app/admin_styled/static/admin/css/changelists.css @@ -0,0 +1,351 @@ +/* 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: 14px; + padding: 5px; + color: var(--body-fg); +} + +#toolbar #searchbar { + height: 19px; + border: 1px solid var(--border-color); + padding: 2px 5px; + margin: 0; + vertical-align: top; + font-size: 13px; + 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: 13px; + 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; +} + +/* 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: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 5px 15px; + margin-bottom: 12px; + border-bottom: none; +} + +#changelist-filter h3 { + font-weight: 400; + padding: 0 15px; + margin-bottom: 10px; +} + +#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); + text-overflow: ellipsis; + overflow-x: hidden; +} + +#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: 13px; + padding-bottom: 10px; + border-bottom: 1px solid var(--hairline-color); +} + +/* DATE DRILLDOWN */ + +.change-list ul.toplinks { + display: block; + float: left; + padding: 0; + margin: 0; + width: 100%; +} + +.change-list ul.toplinks li { + padding: 3px 6px; + font-weight: bold; + list-style-type: none; + display: inline-block; +} + +.change-list ul.toplinks .date-back a { + color: var(--body-quiet-color); +} + +.change-list ul.toplinks .date-back a:focus, +.change-list ul.toplinks .date-back a:hover { + color: var(--link-hover-color); +} + +/* PAGINATOR */ + +.paginator { + font-size: 13px; + 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: 13px; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +/* ACTIONS */ + +.filtered .actions { + border-right: none; +} + +#changelist table input { + margin: 0; + vertical-align: baseline; +} + +#changelist table tbody tr.selected { + background-color: var(--selected-row) !important; +} + +#changelist .actions { + padding: 10px; + background: var(--body-bg); + border-top: none; + border-bottom: none; + line-height: 24px; + color: var(--body-quiet-color); + width: 100%; +} + +#changelist .actions.selected { /* XXX Probably unused? */ + background: var(--body-bg); + border-top: 1px solid var(--body-bg); + border-bottom: 1px solid #edecd6; +} + +#changelist .actions span.all, +#changelist .actions span.action-counter, +#changelist .actions span.clear, +#changelist .actions span.question { + font-size: 13px; + margin: 0 0.5em; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + vertical-align: top; + height: 24px; + color: var(--body-fg); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 14px; + 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: 13px; +} + +#changelist .actions .button { + font-size: 13px; + 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: 24px; + 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/app/contrib/bonde/models.py b/app/contrib/bonde/models.py index d48e61b9..e3f1e5af 100644 --- a/app/contrib/bonde/models.py +++ b/app/contrib/bonde/models.py @@ -138,6 +138,32 @@ class Meta: db_table = "dns_hosted_zones" +class Theme(models.Model): + value = models.TextField(unique=True) + label = models.TextField() + priority = models.IntegerField(blank=True, null=True) + + class Meta: + managed = False + db_table = "themes" + + def __str__(self): + return self.label + + +class Subtheme(models.Model): + value = models.TextField(unique=True) + label = models.TextField() + theme = models.ForeignKey(Theme, models.DO_NOTHING) + + class Meta: + managed = False + db_table = "subthemes" + + def __str__(self): + return self.label + + class MobilizationStatus(models.TextChoices): archived = "archived", "Arquivada" active = "active", "Ativa" @@ -149,12 +175,12 @@ class Mobilization(models.Model): # user_id = models.IntegerField(blank=True, null=True) # color_scheme = models.CharField(max_length=-1, blank=True, null=True) # google_analytics_code = models.CharField(max_length=-1, blank=True, null=True) - # goal = models.TextField(blank=True, null=True) + goal = models.TextField(blank=True, null=True) # header_font = models.CharField(max_length=-1, blank=True, null=True) # body_font = models.CharField(max_length=-1, blank=True, null=True) # facebook_share_title = models.CharField(max_length=-1, blank=True, null=True) # facebook_share_description = models.TextField(blank=True, null=True) - # facebook_share_image = models.CharField(max_length=-1, blank=True, null=True) + facebook_share_image = models.CharField(max_length=255, blank=True, null=True) # slug = models.CharField(unique=True, max_length=-1, blank=True, null=True) custom_domain = models.CharField(unique=True, max_length=255, blank=True, null=True) twitter_share_text = models.CharField(max_length=300, blank=True, null=True) @@ -168,7 +194,8 @@ class Mobilization(models.Model): # traefik_backend_address = models.CharField(max_length=-1, blank=True, null=True) language = models.CharField(max_length=5, blank=True, null=True) updated_at = models.DateTimeField(blank=True, null=True, auto_now=True) - # theme = models.ForeignKey('Themes', models.DO_NOTHING, blank=True, null=True) + theme = models.ForeignKey(Theme, models.DO_NOTHING, blank=True, null=True) + subthemes = models.ManyToManyField(Subtheme, blank=True) objects = RequestManager(lookup_field="community") @@ -196,32 +223,6 @@ class Meta: db_table = "blocks" -class Theme(models.Model): - value = models.TextField(unique=True) - label = models.TextField() - priority = models.IntegerField(blank=True, null=True) - - class Meta: - managed = False - db_table = "themes" - - def __str__(self): - return self.label - - -class Subtheme(models.Model): - value = models.TextField(unique=True) - label = models.TextField() - theme = models.ForeignKey(Theme, models.DO_NOTHING) - - class Meta: - managed = False - db_table = "subthemes" - - def __str__(self): - return self.label - - class WidgetKind(models.TextChoices): content = "content", "Conteúdo" donation = "donation", "Doação" diff --git a/app/contrib/frontend/landpage/migrations/0010_auto_20240117_2042.py b/app/contrib/frontend/landpage/migrations/0010_auto_20240117_2042.py new file mode 100644 index 00000000..473a7cf1 --- /dev/null +++ b/app/contrib/frontend/landpage/migrations/0010_auto_20240117_2042.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2024-01-17 20:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('landpage', '0009_auto_20231020_1853'), + ] + + operations = [ + migrations.AlterField( + model_name='footer', + name='font', + field=models.CharField(blank=True, choices=[('Abel', 'Abel'), ('Anton', 'Anton'), ('Archivo Narrow', 'Archivo Narrow'), ('Arvo', 'Arvo'), ('Asap', 'Asap'), ('Baloo Bhai', 'Baloo Bhai'), ('Bebas Neue Pro', 'Bebas Neue Pro'), ('Bitter', 'Bitter'), ('Bree Serif', 'Bree Serif'), ('Capriola', 'Capriola'), ('Cabin', 'Cabin'), ('Catamaran', 'Catamaran'), ('Crimson Text', 'Crimson Text'), ('Cuprum', 'Cuprum'), ('David Libre', 'David Libre'), ('Dosis', 'Dosis'), ('Droid Sans', 'Droid Sans'), ('Exo', 'Exo'), ('Exo 2', 'Exo 2'), ('Fira Sans', 'Fira Sans'), ('Fjalla One', 'Fjalla One'), ('Francois One', 'Francois One'), ('Gidugu', 'Gidugu'), ('Hind', 'Hind'), ('Inconsolata', 'Inconsolata'), ('Indie Flower', 'Indie Flower'), ('Josefin Sans', 'Josefin Sans'), ('Karla', 'Karla'), ('Lalezar', 'Lalezar'), ('Lato', 'Lato'), ('Libre Baskerville', 'Libre Baskerville'), ('Lobster', 'Lobster'), ('Lora', 'Lora'), ('Merriweather Sans', 'Merriweather Sans'), ('Montserrat', 'Montserrat'), ('Muli', 'Muli'), ('Noto Serif', 'Noto Serif'), ('Nunito Sans', 'Nunito Sans'), ('Open Sans', 'Open Sans'), ('Open Sans Condensed', 'Open Sans Condensed'), ('Oswald', 'Oswald'), ('Oxygen', 'Oxygen'), ('PT Sans', 'PT Sans'), ('PT Serif', 'PT Serif'), ('Pacifico', 'Pacifico'), ('Playfair Display', 'Playfair Display'), ('Poiret One', 'Poiret One'), ('Poppins', 'Poppins'), ('Quicksand', 'Quicksand'), ('Raleway', 'Raleway'), ('Roboto', 'Roboto'), ('Roboto Condensed', 'Roboto Condensed'), ('Roboto Mono', 'Roboto Mono'), ('Roboto Slab', 'Roboto Slab'), ('Ruslan Display', 'Ruslan Display'), ('Signika', 'Signika'), ('Slabo 27px', 'Slabo 27px'), ('Source Sans Pro', 'Source Sans Pro'), ('Titillium Web', 'Titillium Web'), ('Ubuntu', 'Ubuntu'), ('Ubuntu Condensed', 'Ubuntu Condensed'), ('Varela Round', 'Varela Round'), ('Yanone Kaffeesatz', 'Yanone Kaffeesatz')], max_length=100, null=True, verbose_name='Estilo de fonte'), + ), + migrations.AlterField( + model_name='navbar', + name='font', + field=models.CharField(blank=True, choices=[('Abel', 'Abel'), ('Anton', 'Anton'), ('Archivo Narrow', 'Archivo Narrow'), ('Arvo', 'Arvo'), ('Asap', 'Asap'), ('Baloo Bhai', 'Baloo Bhai'), ('Bebas Neue Pro', 'Bebas Neue Pro'), ('Bitter', 'Bitter'), ('Bree Serif', 'Bree Serif'), ('Capriola', 'Capriola'), ('Cabin', 'Cabin'), ('Catamaran', 'Catamaran'), ('Crimson Text', 'Crimson Text'), ('Cuprum', 'Cuprum'), ('David Libre', 'David Libre'), ('Dosis', 'Dosis'), ('Droid Sans', 'Droid Sans'), ('Exo', 'Exo'), ('Exo 2', 'Exo 2'), ('Fira Sans', 'Fira Sans'), ('Fjalla One', 'Fjalla One'), ('Francois One', 'Francois One'), ('Gidugu', 'Gidugu'), ('Hind', 'Hind'), ('Inconsolata', 'Inconsolata'), ('Indie Flower', 'Indie Flower'), ('Josefin Sans', 'Josefin Sans'), ('Karla', 'Karla'), ('Lalezar', 'Lalezar'), ('Lato', 'Lato'), ('Libre Baskerville', 'Libre Baskerville'), ('Lobster', 'Lobster'), ('Lora', 'Lora'), ('Merriweather Sans', 'Merriweather Sans'), ('Montserrat', 'Montserrat'), ('Muli', 'Muli'), ('Noto Serif', 'Noto Serif'), ('Nunito Sans', 'Nunito Sans'), ('Open Sans', 'Open Sans'), ('Open Sans Condensed', 'Open Sans Condensed'), ('Oswald', 'Oswald'), ('Oxygen', 'Oxygen'), ('PT Sans', 'PT Sans'), ('PT Serif', 'PT Serif'), ('Pacifico', 'Pacifico'), ('Playfair Display', 'Playfair Display'), ('Poiret One', 'Poiret One'), ('Poppins', 'Poppins'), ('Quicksand', 'Quicksand'), ('Raleway', 'Raleway'), ('Roboto', 'Roboto'), ('Roboto Condensed', 'Roboto Condensed'), ('Roboto Mono', 'Roboto Mono'), ('Roboto Slab', 'Roboto Slab'), ('Ruslan Display', 'Ruslan Display'), ('Signika', 'Signika'), ('Slabo 27px', 'Slabo 27px'), ('Source Sans Pro', 'Source Sans Pro'), ('Titillium Web', 'Titillium Web'), ('Ubuntu', 'Ubuntu'), ('Ubuntu Condensed', 'Ubuntu Condensed'), ('Varela Round', 'Varela Round'), ('Yanone Kaffeesatz', 'Yanone Kaffeesatz')], max_length=100, null=True, verbose_name='Estilo de fonte'), + ), + ] diff --git a/app/contrib/frontend/migrations/0005_alter_button_font.py b/app/contrib/frontend/migrations/0005_alter_button_font.py new file mode 100644 index 00000000..4e0f7149 --- /dev/null +++ b/app/contrib/frontend/migrations/0005_alter_button_font.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-01-17 20:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('frontend', '0004_auto_20231005_1254'), + ] + + operations = [ + migrations.AlterField( + model_name='button', + name='font', + field=models.CharField(blank=True, choices=[('Abel', 'Abel'), ('Anton', 'Anton'), ('Archivo Narrow', 'Archivo Narrow'), ('Arvo', 'Arvo'), ('Asap', 'Asap'), ('Baloo Bhai', 'Baloo Bhai'), ('Bebas Neue Pro', 'Bebas Neue Pro'), ('Bitter', 'Bitter'), ('Bree Serif', 'Bree Serif'), ('Capriola', 'Capriola'), ('Cabin', 'Cabin'), ('Catamaran', 'Catamaran'), ('Crimson Text', 'Crimson Text'), ('Cuprum', 'Cuprum'), ('David Libre', 'David Libre'), ('Dosis', 'Dosis'), ('Droid Sans', 'Droid Sans'), ('Exo', 'Exo'), ('Exo 2', 'Exo 2'), ('Fira Sans', 'Fira Sans'), ('Fjalla One', 'Fjalla One'), ('Francois One', 'Francois One'), ('Gidugu', 'Gidugu'), ('Hind', 'Hind'), ('Inconsolata', 'Inconsolata'), ('Indie Flower', 'Indie Flower'), ('Josefin Sans', 'Josefin Sans'), ('Karla', 'Karla'), ('Lalezar', 'Lalezar'), ('Lato', 'Lato'), ('Libre Baskerville', 'Libre Baskerville'), ('Lobster', 'Lobster'), ('Lora', 'Lora'), ('Merriweather Sans', 'Merriweather Sans'), ('Montserrat', 'Montserrat'), ('Muli', 'Muli'), ('Noto Serif', 'Noto Serif'), ('Nunito Sans', 'Nunito Sans'), ('Open Sans', 'Open Sans'), ('Open Sans Condensed', 'Open Sans Condensed'), ('Oswald', 'Oswald'), ('Oxygen', 'Oxygen'), ('PT Sans', 'PT Sans'), ('PT Serif', 'PT Serif'), ('Pacifico', 'Pacifico'), ('Playfair Display', 'Playfair Display'), ('Poiret One', 'Poiret One'), ('Poppins', 'Poppins'), ('Quicksand', 'Quicksand'), ('Raleway', 'Raleway'), ('Roboto', 'Roboto'), ('Roboto Condensed', 'Roboto Condensed'), ('Roboto Mono', 'Roboto Mono'), ('Roboto Slab', 'Roboto Slab'), ('Ruslan Display', 'Ruslan Display'), ('Signika', 'Signika'), ('Slabo 27px', 'Slabo 27px'), ('Source Sans Pro', 'Source Sans Pro'), ('Titillium Web', 'Titillium Web'), ('Ubuntu', 'Ubuntu'), ('Ubuntu Condensed', 'Ubuntu Condensed'), ('Varela Round', 'Varela Round'), ('Yanone Kaffeesatz', 'Yanone Kaffeesatz')], max_length=100, null=True, verbose_name='Estilo de fonte'), + ), + ] diff --git a/app/nossas/__init__.py b/app/nossas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/admin.py b/app/nossas/admin.py new file mode 100644 index 00000000..84412c5d --- /dev/null +++ b/app/nossas/admin.py @@ -0,0 +1,4 @@ +from django.contrib import admin +from .models import StyleGuideModel + +admin.site.register(StyleGuideModel) diff --git a/app/nossas/apps.py b/app/nossas/apps.py new file mode 100644 index 00000000..023f1bc4 --- /dev/null +++ b/app/nossas/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NossasConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'nossas' \ No newline at end of file diff --git a/app/nossas/apps/__init__.py b/app/nossas/apps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/baseadmin.py b/app/nossas/apps/baseadmin.py new file mode 100644 index 00000000..760ca0f9 --- /dev/null +++ b/app/nossas/apps/baseadmin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from translated_fields import TranslatedFieldAdmin + + +class OnSiteAdmin(TranslatedFieldAdmin, admin.ModelAdmin): + + def get_fields(self, request, obj): + fields = super().get_fields(request, obj) + fields.remove("site") + return fields + + def save_model(self, request, obj, form, change): + if not change: + obj.site = request.current_site + + return super().save_model(request, obj, form, change) + + def get_queryset(self, request): + qs = self.model.on_site.get_queryset() + # TODO: this should be handled by some parameter to the ChangeList. + ordering = self.get_ordering(request) + if ordering: + qs = qs.order_by(*ordering) + return qs + # return super().get_queryset(request) \ No newline at end of file diff --git a/app/nossas/apps/basemodel.py b/app/nossas/apps/basemodel.py new file mode 100644 index 00000000..aabcd05a --- /dev/null +++ b/app/nossas/apps/basemodel.py @@ -0,0 +1,12 @@ +from django.db import models +from django.contrib.sites.models import Site +from django.contrib.sites.managers import CurrentSiteManager + + +class OnSiteBaseModel(models.Model): + site = models.ForeignKey(Site, on_delete=models.CASCADE) + + on_site = CurrentSiteManager() + + class Meta: + abstract = True diff --git a/app/nossas/apps/campaigns/__init__.py b/app/nossas/apps/campaigns/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/campaigns/admin.py b/app/nossas/apps/campaigns/admin.py new file mode 100644 index 00000000..8bb0407a --- /dev/null +++ b/app/nossas/apps/campaigns/admin.py @@ -0,0 +1,68 @@ +from django.contrib import admin + +from django.urls import path +from django.http.response import HttpResponseRedirect +from django.utils.translation import ugettext_lazy as _ +from django.utils.safestring import mark_safe + +from nossas.apps.baseadmin import OnSiteAdmin + +from .models import Campaign +from .utils import import_mobilization + + +@admin.action(description=_("Mostrar todas as campanhas selecionadas")) +def show(modeladmin, request, queryset): + queryset.update(hide=False) + + +class CampaignAdmin(OnSiteAdmin): + list_display = ("name", "release_date", "status", "tag_list", "get_picture", "hide") + change_list_template = "admin/campaigns/changelist.html" + list_filter = ["hide", "campaign_group", "status", "tags"] + search_fields = ["name", "status"] + actions = [show] + + def tag_list(self, obj): + return mark_safe( + "" + ) + + tag_list.short_description = _("Marcadores") + + def get_picture(self, obj): + if obj.picture: + return mark_safe( + f"""""" + ) + + return "-" + + get_picture.short_description = _("Imagem") + + def get_urls(self): + urls = super().get_urls() + + my_urls = [ + path("import/", self.import_mobilization), + ] + + return my_urls + urls + + def import_mobilization(self, request): + if request.method == "POST": + mobilization_id = request.POST.get("mobilization_id") + current_site = request.current_site + current_user = request.user + + if mobilization_id: + import_mobilization(mobilization_id, current_site, current_user) + + self.message_user(request, "Campanha importada com sucesso", "SUCCESS") + + return HttpResponseRedirect("../") + + +admin.site.register(Campaign, CampaignAdmin) diff --git a/app/nossas/apps/campaigns/cms_apps.py b/app/nossas/apps/campaigns/cms_apps.py new file mode 100644 index 00000000..9227de8d --- /dev/null +++ b/app/nossas/apps/campaigns/cms_apps.py @@ -0,0 +1,13 @@ +from django.utils.translation import gettext_lazy as _ + +from cms.app_base import CMSApp +from cms.apphook_pool import apphook_pool + + +@apphook_pool.register +class CampaignsApphook(CMSApp): + app_name = "campaigns" + name = _("Campanhas") + + def get_urls(self, page=None, language=None, **kwargs): + return ["nossas.apps.campaigns.urls"] diff --git a/app/nossas/apps/campaigns/cms_plugins.py b/app/nossas/apps/campaigns/cms_plugins.py new file mode 100644 index 00000000..5e30efd8 --- /dev/null +++ b/app/nossas/apps/campaigns/cms_plugins.py @@ -0,0 +1,42 @@ +import operator +from django.contrib import admin +from django.db.models import Q +from functools import reduce + +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool + +from .models import Campaign, CampaignListPluginModel, QueryCampaignList + + +class QueryCampaignListInline(admin.StackedInline): + model = QueryCampaignList + extra = 1 + + +@plugin_pool.register_plugin +class CampaignListPlugin(CMSPluginBase): + name = "Listagem de Campanhas" + module = "NOSSAS" + # model = CampaignListPluginModel + # inlines = [ + # QueryCampaignListInline, + # ] + render_template = "plugins/filter_campaign_list_plugin.html" + + # def get_filter_list(self, instance, qs): + # queryfilters = list( + # map(lambda x: Q(**x.get_qs_filter()), instance.queries.all()) + # ) + + # if len(queryfilters) > 0: + # return qs.filter(reduce(operator.or_, queryfilters)) + + # return qs + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + + context.update({"campaign_list": Campaign.on_site.filter(hide=False)}) + + return context diff --git a/app/nossas/apps/campaigns/management/__init__.py b/app/nossas/apps/campaigns/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/campaigns/management/commands/__init__.py b/app/nossas/apps/campaigns/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/campaigns/management/commands/import_campaigns.py b/app/nossas/apps/campaigns/management/commands/import_campaigns.py new file mode 100644 index 00000000..2a137118 --- /dev/null +++ b/app/nossas/apps/campaigns/management/commands/import_campaigns.py @@ -0,0 +1,66 @@ +import itertools +from django.core.management.base import BaseCommand, CommandError, CommandParser +from django.db.models import Q, Prefetch +from django.contrib.sites.models import Site +from django.contrib.auth.models import User + +from contrib.bonde.models import Community, Mobilization +from nossas.apps.campaigns.utils import import_mobilization + +# from polls.models import Question as Poll + + +class Command(BaseCommand): + def add_arguments(self, parser: CommandParser) -> None: + parser.add_argument("--period", type=str) + + def handle(self, *args, **options): + q = Q() + for name in [ + "Mobilizações NOSSAS", + "Rede nossas cidades", + "Beta", + "Ninguém fica pra trás", + "Minha Sampa", + "Minha Manaus", + "Minha BH", + "Meu Rio", + "Amazônia de pé", + ]: + q |= Q(name__icontains=name) + + filters = {"status": "active"} + + if options["period"]: + year_start, year_end = options["period"].split(",") + filters.update( + { + "created_at__year__gte": int(year_start), + "created_at__year__lte": int(year_end), + } + ) + + qs = Community.objects.filter(q).prefetch_related( + Prefetch( + "mobilization_set", + queryset=Mobilization.objects.filter(**filters), + ) + ) + + mobilizations = list( + itertools.chain.from_iterable(map(lambda x: x.mobilization_set.all(), qs)) + ) + + site = Site.objects.get(name="NOSSAS") + user = User.objects.get(username="igor@nossas.org") + + for m in mobilizations: + try: + import_mobilization(m.id, site, user) + except Exception as err: + import ipdb;ipdb.set_trace() + self.stdout.write( + self.style.ERROR( + f"Falha ao tentar importar a Mobilização[{m.id}]: {m.name}." + ) + ) diff --git a/app/nossas/apps/campaigns/migrations/0001_initial.py b/app/nossas/apps/campaigns/migrations/0001_initial.py new file mode 100644 index 00000000..b796f13e --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2 on 2023-12-31 14:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Campaign', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=180)), + ('description_pt_br', models.TextField(verbose_name='description')), + ('description_en', models.TextField(blank=True, verbose_name='description')), + ('status', models.CharField(choices=[('opened', 'Aberta'), ('closed', 'Fechada')], max_length=6)), + ('picture', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL)), + ], + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0002_auto_20231231_1432.py b/app/nossas/apps/campaigns/migrations/0002_auto_20231231_1432.py new file mode 100644 index 00000000..13a66c8e --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0002_auto_20231231_1432.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2 on 2023-12-31 14:32 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('campaigns', '0001_initial'), + ] + + operations = [ + migrations.AlterModelManagers( + name='campaign', + managers=[ + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.AddField( + model_name='campaign', + name='site', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='sites.site'), + preserve_default=False, + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0003_campaignlistpluginmodel_querycampaignlist.py b/app/nossas/apps/campaigns/migrations/0003_campaignlistpluginmodel_querycampaignlist.py new file mode 100644 index 00000000..80e1e46b --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0003_campaignlistpluginmodel_querycampaignlist.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2 on 2023-12-31 15:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0022_auto_20180620_1551'), + ('campaigns', '0002_auto_20231231_1432'), + ] + + operations = [ + migrations.CreateModel( + name='CampaignListPluginModel', + fields=[ + ('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='campaigns_campaignlistpluginmodel', serialize=False, to='cms.cmsplugin')), + ], + options={ + 'abstract': False, + }, + bases=('cms.cmsplugin',), + ), + migrations.CreateModel( + name='QueryCampaignList', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='queries', to='campaigns.campaignlistpluginmodel')), + ], + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0004_auto_20231231_1558.py b/app/nossas/apps/campaigns/migrations/0004_auto_20231231_1558.py new file mode 100644 index 00000000..1e81ddd4 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0004_auto_20231231_1558.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2 on 2023-12-31 15:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0003_campaignlistpluginmodel_querycampaignlist'), + ] + + operations = [ + migrations.AddField( + model_name='querycampaignlist', + name='attribute_name', + field=models.CharField(choices=[('status', 'Status'), ('or', 'OU'), ('not', 'Não')], default='status', max_length=10), + preserve_default=False, + ), + migrations.AddField( + model_name='querycampaignlist', + name='value', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0005_auto_20240103_1425.py b/app/nossas/apps/campaigns/migrations/0005_auto_20240103_1425.py new file mode 100644 index 00000000..a5be81ee --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0005_auto_20240103_1425.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2024-01-03 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0004_auto_20231231_1558'), + ] + + operations = [ + migrations.AlterField( + model_name='campaign', + name='status', + field=models.CharField(choices=[('opened', 'Aberta'), ('closed', 'Fechada'), ('done', 'Concluída')], max_length=6), + ), + migrations.AlterField( + model_name='querycampaignlist', + name='attribute_name', + field=models.CharField(choices=[('status', 'Status')], max_length=20), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0006_auto_20240115_1525.py b/app/nossas/apps/campaigns/migrations/0006_auto_20240115_1525.py new file mode 100644 index 00000000..7a16ac1e --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0006_auto_20240115_1525.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2 on 2024-01-15 15:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ('campaigns', '0005_auto_20240103_1425'), + ] + + operations = [ + migrations.AddField( + model_name='campaign', + name='header_image', + field=filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='campaign_header_image', to=settings.FILER_IMAGE_MODEL, verbose_name='Cabeçalho Imagem'), + ), + migrations.AddField( + model_name='campaign', + name='hide', + field=models.BooleanField(blank=True, null=True, verbose_name='Esconder Campanha'), + ), + migrations.AddField( + model_name='campaign', + name='url', + field=models.URLField(blank=True, null=True, verbose_name='Link da Campanha'), + ), + migrations.AlterField( + model_name='campaign', + name='description_en', + field=models.TextField(blank=True, verbose_name='Nome da campanha'), + ), + migrations.AlterField( + model_name='campaign', + name='description_pt_br', + field=models.TextField(verbose_name='Nome da campanha'), + ), + migrations.AlterField( + model_name='campaign', + name='name', + field=models.CharField(max_length=180, verbose_name='Nome da campanha'), + ), + migrations.AlterField( + model_name='campaign', + name='picture', + field=filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL, verbose_name='Imagem'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0007_campaign_photos_placeholder.py b/app/nossas/apps/campaigns/migrations/0007_campaign_photos_placeholder.py new file mode 100644 index 00000000..c415d273 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0007_campaign_photos_placeholder.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2 on 2024-01-15 15:38 + +import cms.models.fields +from django.db import migrations +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0022_auto_20180620_1551'), + ('campaigns', '0006_auto_20240115_1525'), + ] + + operations = [ + migrations.AddField( + model_name='campaign', + name='photos_placeholder', + field=cms.models.fields.PlaceholderField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, slotname='campaign_photos_placeholder', to='cms.placeholder'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0008_campaign_tags.py b/app/nossas/apps/campaigns/migrations/0008_campaign_tags.py new file mode 100644 index 00000000..7979b6e5 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0008_campaign_tags.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2 on 2024-01-15 15:46 + +from django.db import migrations +import tag_fields.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tag_fields', '0001_initial'), + ('campaigns', '0007_campaign_photos_placeholder'), + ] + + operations = [ + migrations.AddField( + model_name='campaign', + name='tags', + field=tag_fields.managers.ModelTagsManager(help_text='A comma-separated list of tags.', through='tag_fields.ModelTagIntFk', to='tag_fields.ModelTag', verbose_name='Tags'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0009_auto_20240115_1608.py b/app/nossas/apps/campaigns/migrations/0009_auto_20240115_1608.py new file mode 100644 index 00000000..9eb47e4d --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0009_auto_20240115_1608.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2024-01-15 16:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0008_campaign_tags'), + ] + + operations = [ + migrations.AddField( + model_name='campaign', + name='release_date', + field=models.DateField(blank=True, null=True, verbose_name='Data de lançamento da Campanha'), + ), + migrations.AlterField( + model_name='campaign', + name='hide', + field=models.BooleanField(default=False, verbose_name='Esconder Campanha'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0010_auto_20240116_2039.py b/app/nossas/apps/campaigns/migrations/0010_auto_20240116_2039.py new file mode 100644 index 00000000..61ecc6f5 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0010_auto_20240116_2039.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2024-01-16 20:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0009_auto_20240115_1608'), + ] + + operations = [ + migrations.AlterField( + model_name='campaign', + name='hide', + field=models.BooleanField(default=False, verbose_name='Esconder'), + ), + migrations.AlterField( + model_name='campaign', + name='status', + field=models.CharField(choices=[('opened', 'Aberta'), ('closed', 'Fechada'), ('done', 'Concluída')], max_length=6, verbose_name='Status'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0011_auto_20240117_1535.py b/app/nossas/apps/campaigns/migrations/0011_auto_20240117_1535.py new file mode 100644 index 00000000..1b516bb3 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0011_auto_20240117_1535.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2 on 2024-01-17 15:35 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('campaigns', '0010_auto_20240116_2039'), + ] + + operations = [ + migrations.AddField( + model_name='campaign', + name='mobilization_id', + field=models.IntegerField(blank=True, null=True, verbose_name='ID da Mobilização BONDE'), + ), + migrations.CreateModel( + name='CampaignGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=180, verbose_name='Nome da grupo')), + ('community_id', models.IntegerField(verbose_name='ID da Comunidade BONDE')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.AddField( + model_name='campaign', + name='campaign_group', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='campaigns.campaigngroup'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0012_alter_campaigngroup_unique_together.py b/app/nossas/apps/campaigns/migrations/0012_alter_campaigngroup_unique_together.py new file mode 100644 index 00000000..9c976bb7 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0012_alter_campaigngroup_unique_together.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-01-17 15:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('campaigns', '0011_auto_20240117_1535'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='campaigngroup', + unique_together={('site', 'community_id')}, + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/0013_auto_20240117_1934.py b/app/nossas/apps/campaigns/migrations/0013_auto_20240117_1934.py new file mode 100644 index 00000000..228ed587 --- /dev/null +++ b/app/nossas/apps/campaigns/migrations/0013_auto_20240117_1934.py @@ -0,0 +1,48 @@ +# Generated by Django 3.2 on 2024-01-17 19:34 + +import cms.models.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0022_auto_20180620_1551'), + ('campaigns', '0012_alter_campaigngroup_unique_together'), + ] + + operations = [ + migrations.AlterModelOptions( + name='campaign', + options={'verbose_name': 'Campanha'}, + ), + migrations.AlterModelOptions( + name='campaigngroup', + options={'verbose_name': 'Comunidade'}, + ), + migrations.RemoveField( + model_name='campaign', + name='photos_placeholder', + ), + migrations.AddField( + model_name='campaign', + name='placeholder', + field=cms.models.fields.PlaceholderField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, slotname='campaign_placeholder', to='cms.placeholder'), + ), + migrations.AlterField( + model_name='campaign', + name='campaign_group', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='campaigns.campaigngroup', verbose_name='Comunidade'), + ), + migrations.AlterField( + model_name='campaign', + name='description_en', + field=models.TextField(blank=True, verbose_name='Descrição'), + ), + migrations.AlterField( + model_name='campaign', + name='description_pt_br', + field=models.TextField(verbose_name='Descrição'), + ), + ] diff --git a/app/nossas/apps/campaigns/migrations/__init__.py b/app/nossas/apps/campaigns/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/campaigns/models.py b/app/nossas/apps/campaigns/models.py new file mode 100644 index 00000000..1068f09c --- /dev/null +++ b/app/nossas/apps/campaigns/models.py @@ -0,0 +1,102 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ + +from cms.models.fields import PlaceholderField +from cms.models.pluginmodel import CMSPlugin +from filer.fields.image import FilerImageField +from translated_fields import TranslatedField +from tag_fields.managers import ModelTagsManager + +from nossas.apps.basemodel import OnSiteBaseModel + + +class CampaignGroup(OnSiteBaseModel): + name = models.CharField(_("Nome da grupo"), max_length=180) + community_id = models.IntegerField(_("ID da Comunidade BONDE")) + + class Meta: + unique_together = ("site", "community_id") + verbose_name = _("Comunidade") + + def __str__(self): + return self.name + + +class CampaignStatus(models.TextChoices): + opened = "opened", "Aberta" + closed = "closed", "Fechada" + done = "done", "Concluída" + + +class Campaign(OnSiteBaseModel): + name = models.CharField(_("Nome da campanha"), max_length=180) + description = TranslatedField( + models.TextField(_("Descrição")), {"en": {"blank": True}} + ) + picture = FilerImageField( + verbose_name=_("Imagem"), on_delete=models.SET_NULL, blank=True, null=True + ) + status = models.CharField(_("Status"), max_length=6, choices=CampaignStatus.choices) + + campaign_group = models.ForeignKey( + CampaignGroup, verbose_name=_("Comunidade"), on_delete=models.CASCADE, null=True + ) + mobilization_id = models.IntegerField( + _("ID da Mobilização BONDE"), null=True, blank=True + ) + + header_image = FilerImageField( + verbose_name=_("Cabeçalho Imagem"), + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="campaign_header_image", + ) + url = models.URLField(_("Link da Campanha"), null=True, blank=True) + release_date = models.DateField( + _("Data de lançamento"), null=True, blank=True + ) + hide = models.BooleanField(_("Esconder"), default=False) + + placeholder = PlaceholderField("campaign_placeholder") + + # + tags = ModelTagsManager() + + class Meta: + verbose_name = _("Campanha") + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("campaigns:campaign-detail", kwargs={"pk": self.pk}) + + +class CampaignListPluginModel(CMSPlugin): + def copy_relations(self, old_instance): + self.queries.all().delete() + + for query_obj in old_instance.queries.all(): + # instance.pk = None; instance.pk.save() is the slightly odd but + # standard Django way of copying a saved model instance + query_obj.pk = None + query_obj.plugin = self + query_obj.save() + + +class QueryAttributes(models.TextChoices): + status = "status", "Status" + + +class QueryCampaignList(models.Model): + attribute_name = models.CharField(max_length=20, choices=QueryAttributes.choices) + value = models.CharField(max_length=255, null=True, blank=True) + + plugin = models.ForeignKey( + CampaignListPluginModel, related_name="queries", on_delete=models.CASCADE + ) + + def get_qs_filter(self): + return {self.attribute_name: self.value} diff --git a/app/nossas/apps/campaigns/static/campaigns/css/admin.css b/app/nossas/apps/campaigns/static/campaigns/css/admin.css new file mode 100644 index 00000000..59609029 --- /dev/null +++ b/app/nossas/apps/campaigns/static/campaigns/css/admin.css @@ -0,0 +1,19 @@ +.object-tools { + display: flex; +} + +.campaign-object-tools-import form { + display: flex; + margin: 0; + padding: 0; + background: none; + box-shadow: none; +} + +.campaign-object-tools-import form input { + width: 30px; +} + +.campaign-object-tools-import form [type="submit"] { + width: 25px; +} \ No newline at end of file diff --git a/app/nossas/apps/campaigns/templates/admin/campaigns/changelist.html b/app/nossas/apps/campaigns/templates/admin/campaigns/changelist.html new file mode 100644 index 00000000..619cb149 --- /dev/null +++ b/app/nossas/apps/campaigns/templates/admin/campaigns/changelist.html @@ -0,0 +1,18 @@ +{% extends 'admin/change_list.html' %} +{% load static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block object-tools-items %} +
  • +
    + {% csrf_token %} + + +
    +
  • + {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/app/nossas/apps/campaigns/templates/nossas/campaigns/campaign_detail.html b/app/nossas/apps/campaigns/templates/nossas/campaigns/campaign_detail.html new file mode 100644 index 00000000..106954c5 --- /dev/null +++ b/app/nossas/apps/campaigns/templates/nossas/campaigns/campaign_detail.html @@ -0,0 +1,25 @@ +{% extends 'nossas/home.html' %} +{% load i18n cms_tags %} + +{% block content %} +{% include "nossas/plugins/navbar.html" with instance=navbar %} +
    +
    + +
    + {% include "nossas/graphics/group_21.svg" %} +
    +
    +
    +

    {{ object.name }}

    + +
    + {% render_placeholder object.placeholder %} +
    +
    +
    + +{% endblock %} \ No newline at end of file diff --git a/app/nossas/apps/campaigns/templates/plugins/filter_campaign_list_plugin.html b/app/nossas/apps/campaigns/templates/plugins/filter_campaign_list_plugin.html new file mode 100644 index 00000000..329615e1 --- /dev/null +++ b/app/nossas/apps/campaigns/templates/plugins/filter_campaign_list_plugin.html @@ -0,0 +1,5 @@ +
    + {% for campaign in campaign_list %} + {% include 'plugins/filter_campaign_list_plugin_item.html' with instance=campaign %} + {% endfor %} +
    \ No newline at end of file diff --git a/app/nossas/apps/campaigns/templates/plugins/filter_campaign_list_plugin_item.html b/app/nossas/apps/campaigns/templates/plugins/filter_campaign_list_plugin_item.html new file mode 100644 index 00000000..4da806af --- /dev/null +++ b/app/nossas/apps/campaigns/templates/plugins/filter_campaign_list_plugin_item.html @@ -0,0 +1,11 @@ +{% load sekizai_tags %} + +
    + + {{ instance.get_status_display }} +
    +

    {{ instance.name }}

    +

    {{ instance.description }}

    +
    +
    +
    \ No newline at end of file diff --git a/app/nossas/apps/campaigns/urls.py b/app/nossas/apps/campaigns/urls.py new file mode 100644 index 00000000..9d6d6dbb --- /dev/null +++ b/app/nossas/apps/campaigns/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import CampaignDetailView + + +urlpatterns = [ + path("/", CampaignDetailView.as_view(), name="campaign-detail"), +] \ No newline at end of file diff --git a/app/nossas/apps/campaigns/utils.py b/app/nossas/apps/campaigns/utils.py new file mode 100644 index 00000000..23253c77 --- /dev/null +++ b/app/nossas/apps/campaigns/utils.py @@ -0,0 +1,70 @@ +import urllib +from django.core.files import File as DjangoFile +from filer.models import Image +from contrib.bonde.models import Mobilization, MobilizationStatus +from .models import Campaign, CampaignStatus, CampaignGroup + + +def create_filer_image(user, filename, datafile): + owner = user + file_obj = DjangoFile(open(datafile, "rb"), name=filename) + image = Image.objects.create(owner=owner, original_filename=filename, file=file_obj) + return image + + +def import_mobilization(mobilization_id, current_site, current_user): + mobilization = Mobilization.objects.get(id=mobilization_id) + + updated = False + campaign_group, created = CampaignGroup.on_site.get_or_create( + name=mobilization.community.name, + community_id=mobilization.community.id, + site=current_site + ) + + campaign = Campaign.on_site.create( + name=mobilization.name, + description_pt_br=mobilization.goal, + mobilization_id=mobilization.id, + hide=True, + status=CampaignStatus.closed + if mobilization.status != MobilizationStatus.active + else CampaignStatus.opened, + campaign_group=campaign_group, + site=current_site, + ) + + if mobilization.custom_domain: + updated = True + campaign.url = "https://" + mobilization.custom_domain + + if mobilization.theme: + updated = True + campaign.tags.add(mobilization.theme.label) + + if mobilization.subthemes.exists(): + updated = True + campaign.tags.add( + *list(map(lambda x: x.label, mobilization.subthemes.all())) + ) + + if mobilization.facebook_share_image: + updated = True + result = urllib.request.urlretrieve( + mobilization.facebook_share_image + ) + + image = create_filer_image( + current_user, f"mobilization_{mobilization.id}_image", result[0] + ) + + campaign.picture = image + + if mobilization.created_at: + updated = True + campaign.release_date = mobilization.created_at + + if updated: + campaign.save() + + return campaign \ No newline at end of file diff --git a/app/nossas/apps/campaigns/views.py b/app/nossas/apps/campaigns/views.py new file mode 100644 index 00000000..296c8ce7 --- /dev/null +++ b/app/nossas/apps/campaigns/views.py @@ -0,0 +1,19 @@ +from django.views.generic.detail import DetailView + +from .models import Campaign + + +class CampaignDetailView(DetailView): + model = Campaign + template_name = "nossas/campaigns/campaign_detail.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context.update( + { + "navbar": {"classes": "bg-vermelho-nossas"}, + } + ) + + return context \ No newline at end of file diff --git a/app/nossas/apps/institutional/__init__.py b/app/nossas/apps/institutional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/institutional/admin.py b/app/nossas/apps/institutional/admin.py new file mode 100644 index 00000000..eab2e19d --- /dev/null +++ b/app/nossas/apps/institutional/admin.py @@ -0,0 +1,36 @@ +from django.contrib import admin + +from .forms import InstitutionalInformationForm +from .models import InstitutionalInformation + + +class InstitutionalInformationAdmin(admin.ModelAdmin): + form = InstitutionalInformationForm + fieldsets = ( + ( + None, + { + "fields": ( + "address_line", + ("city", "state"), + "zipcode", + ), + }, + ), + ( + "Contato", + { + "fields": (("contact_mail", "contact_phone"),), + }, + ), + ) + change_form_template = "admin/institutional/change_form.html" + + def save_model(self, request, obj, form, change): + if not change: + obj.site = request.current_site + + return super().save_model(request, obj, form, change) + + +admin.site.register(InstitutionalInformation, InstitutionalInformationAdmin) diff --git a/app/nossas/apps/institutional/cms_toolbars.py b/app/nossas/apps/institutional/cms_toolbars.py new file mode 100644 index 00000000..7d09ad4d --- /dev/null +++ b/app/nossas/apps/institutional/cms_toolbars.py @@ -0,0 +1,21 @@ +from cms.toolbar_base import CMSToolbar +from cms.toolbar_pool import toolbar_pool +from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER +from cms.utils.urlutils import admin_reverse, reverse + + +@toolbar_pool.register +class InstitutionalToolbar(CMSToolbar): + def populate(self): + admin_menu = self.toolbar.get_or_create_menu( + ADMIN_MENU_IDENTIFIER, self.current_site.name + ) + + # Create view to redirect add or change admin url based on check request.current_site + url = reverse("institutional:redirect_add_or_change") + + admin_menu.add_modal_item( + "Informações", + url=url, + position=0, + ) diff --git a/app/nossas/apps/institutional/forms.py b/app/nossas/apps/institutional/forms.py new file mode 100644 index 00000000..3fcaa456 --- /dev/null +++ b/app/nossas/apps/institutional/forms.py @@ -0,0 +1,18 @@ +from django import forms + + +class InstitutionalInformationForm(forms.ModelForm): + + class Meta: + widgets = { + "zipcode": forms.TextInput( + attrs={ + "data-mask": "00.000-000", + } + ), + "contact_phone": forms.TextInput( + attrs={ + "data-mask": "(00) 0 0000-0000", + } + ), + } \ No newline at end of file diff --git a/app/nossas/apps/institutional/migrations/0001_initial.py b/app/nossas/apps/institutional/migrations/0001_initial.py new file mode 100644 index 00000000..cdc2ff65 --- /dev/null +++ b/app/nossas/apps/institutional/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2 on 2024-01-09 14:01 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ] + + operations = [ + migrations.CreateModel( + name='InstitutionalInformation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('address_line', models.CharField(max_length=255, verbose_name='Endereço')), + ('city', models.CharField(max_length=100, verbose_name='Cidade')), + ('state', models.CharField(max_length=2, verbose_name='UF')), + ('zipcode', models.CharField(max_length=8, verbose_name='CEP')), + ('contact_mail', models.EmailField(max_length=254, verbose_name='E-mail de contato')), + ('contact_phone', models.CharField(max_length=15, verbose_name='Telefone de contato')), + ('site', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Informações Institucionais', + }, + managers=[ + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + ] diff --git a/app/nossas/apps/institutional/migrations/0002_auto_20240111_2048.py b/app/nossas/apps/institutional/migrations/0002_auto_20240111_2048.py new file mode 100644 index 00000000..ed901443 --- /dev/null +++ b/app/nossas/apps/institutional/migrations/0002_auto_20240111_2048.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2 on 2024-01-11 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutional', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='institutionalinformation', + name='contact_phone', + field=models.CharField(max_length=16, verbose_name='Telefone de contato'), + ), + migrations.AlterField( + model_name='institutionalinformation', + name='zipcode', + field=models.CharField(max_length=9, verbose_name='CEP'), + ), + ] diff --git a/app/nossas/apps/institutional/migrations/__init__.py b/app/nossas/apps/institutional/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/institutional/models.py b/app/nossas/apps/institutional/models.py new file mode 100644 index 00000000..473ad160 --- /dev/null +++ b/app/nossas/apps/institutional/models.py @@ -0,0 +1,20 @@ +from django.db import models +from django.contrib.sites.models import Site +from django.contrib.sites.managers import CurrentSiteManager +from django.utils.translation import ugettext_lazy as _ + + +class InstitutionalInformation(models.Model): + address_line = models.CharField(_("Endereço"), max_length=255) + city = models.CharField(_("Cidade"), max_length=100) + state = models.CharField(_("UF"), max_length=2) + zipcode = models.CharField(_("CEP"), max_length=9) + contact_mail = models.EmailField(_("E-mail de contato")) + contact_phone = models.CharField(_("Telefone de contato"), max_length=16) + + site = models.OneToOneField(Site, on_delete=models.CASCADE) + + on_site = CurrentSiteManager() + + class Meta: + verbose_name = _("Informações Institucionais") diff --git a/app/nossas/apps/institutional/templates/admin/institutional/change_form.html b/app/nossas/apps/institutional/templates/admin/institutional/change_form.html new file mode 100644 index 00000000..96b13e09 --- /dev/null +++ b/app/nossas/apps/institutional/templates/admin/institutional/change_form.html @@ -0,0 +1,9 @@ +{% extends 'admin/change_form.html' %} +{% load static %} + +{% block admin_change_form_document_ready %} +{{ block.super }} + + +{% endblock %} \ No newline at end of file diff --git a/app/nossas/apps/institutional/urls.py b/app/nossas/apps/institutional/urls.py new file mode 100644 index 00000000..b412e757 --- /dev/null +++ b/app/nossas/apps/institutional/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import redirect_add_or_change + + +urlpatterns = [ + path("redirect-add-or-change/", redirect_add_or_change, name="redirect_add_or_change"), +] \ No newline at end of file diff --git a/app/nossas/apps/institutional/views.py b/app/nossas/apps/institutional/views.py new file mode 100644 index 00000000..1370a017 --- /dev/null +++ b/app/nossas/apps/institutional/views.py @@ -0,0 +1,22 @@ +# from django.contrib import admin +from django.shortcuts import redirect +# from django.urls import path + +from cms.utils.urlutils import admin_reverse + +from .models import InstitutionalInformation + + +def redirect_add_or_change(request): + url = admin_reverse("institutional_institutionalinformation_add") + + try: + if request.current_site.institutionalinformation: + url = admin_reverse( + "institutional_institutionalinformation_change", + kwargs={"object_id": request.current_site.institutionalinformation.id}, + ) + except InstitutionalInformation.DoesNotExist: + pass + + return redirect(url) \ No newline at end of file diff --git a/app/nossas/apps/jobs/__init__.py b/app/nossas/apps/jobs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/jobs/admin.py b/app/nossas/apps/jobs/admin.py new file mode 100644 index 00000000..de344e8f --- /dev/null +++ b/app/nossas/apps/jobs/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from nossas.apps.baseadmin import OnSiteAdmin +from .models import Job + + +class JobAdmin(OnSiteAdmin): + list_display = ("title", "status", "created_at") + + +admin.site.register(Job, JobAdmin) diff --git a/app/nossas/apps/jobs/cms_apps.py b/app/nossas/apps/jobs/cms_apps.py new file mode 100644 index 00000000..c4607edf --- /dev/null +++ b/app/nossas/apps/jobs/cms_apps.py @@ -0,0 +1,13 @@ +from django.utils.translation import gettext_lazy as _ + +from cms.app_base import CMSApp +from cms.apphook_pool import apphook_pool + + +@apphook_pool.register +class JobsApphook(CMSApp): + app_name = "jobs" + name = _("Vagas") + + def get_urls(self, page=None, language=None, **kwargs): + return ["nossas.apps.jobs.urls"] diff --git a/app/nossas/apps/jobs/cms_plugins.py b/app/nossas/apps/jobs/cms_plugins.py new file mode 100644 index 00000000..94138995 --- /dev/null +++ b/app/nossas/apps/jobs/cms_plugins.py @@ -0,0 +1,20 @@ +from django.db.models import Q + +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool + +from .models import Job, JobStatus + + +@plugin_pool.register_plugin +class SliderJobsPlugin(CMSPluginBase): + name = "Slide de Vagas" + module = "NOSSAS" + render_template = "nossas/jobs/plugins/slider_jobs_plugin.html" + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + + context.update({"job_list": Job.on_site.filter(~Q(status=JobStatus.closed))}) + + return context diff --git a/app/nossas/apps/jobs/migrations/0001_initial.py b/app/nossas/apps/jobs/migrations/0001_initial.py new file mode 100644 index 00000000..94170e15 --- /dev/null +++ b/app/nossas/apps/jobs/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2 on 2024-01-03 14:25 + +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion +import djangocms_text_ckeditor.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ] + + operations = [ + migrations.CreateModel( + name='Job', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100)), + ('workload', models.CharField(max_length=50)), + ('condition', models.CharField(max_length=50)), + ('responsibilities', djangocms_text_ckeditor.fields.HTMLField()), + ('prerequisites', djangocms_text_ckeditor.fields.HTMLField()), + ('benefits', djangocms_text_ckeditor.fields.HTMLField()), + ('salary_estimate', models.CharField(max_length=80)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + ] diff --git a/app/nossas/apps/jobs/migrations/0002_auto_20240103_1431.py b/app/nossas/apps/jobs/migrations/0002_auto_20240103_1431.py new file mode 100644 index 00000000..b14cec27 --- /dev/null +++ b/app/nossas/apps/jobs/migrations/0002_auto_20240103_1431.py @@ -0,0 +1,59 @@ +# Generated by Django 3.2 on 2024-01-03 14:31 + +from django.db import migrations, models +import djangocms_text_ckeditor.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('jobs', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='job', + options={'verbose_name': 'Vaga', 'verbose_name_plural': 'Vagas'}, + ), + migrations.AddField( + model_name='job', + name='description', + field=djangocms_text_ckeditor.fields.HTMLField(default='', verbose_name='Descrição da vaga'), + preserve_default=False, + ), + migrations.AlterField( + model_name='job', + name='benefits', + field=djangocms_text_ckeditor.fields.HTMLField(verbose_name='Benefícios'), + ), + migrations.AlterField( + model_name='job', + name='condition', + field=models.CharField(max_length=50, verbose_name='Condições de trabalho'), + ), + migrations.AlterField( + model_name='job', + name='prerequisites', + field=djangocms_text_ckeditor.fields.HTMLField(verbose_name='Pré-requisitos da vaga'), + ), + migrations.AlterField( + model_name='job', + name='responsibilities', + field=djangocms_text_ckeditor.fields.HTMLField(verbose_name='Responsabilidades da vaga'), + ), + migrations.AlterField( + model_name='job', + name='salary_estimate', + field=models.CharField(max_length=80, verbose_name='Estimativa salarial'), + ), + migrations.AlterField( + model_name='job', + name='title', + field=models.CharField(max_length=100, verbose_name='Título da vaga'), + ), + migrations.AlterField( + model_name='job', + name='workload', + field=models.CharField(max_length=50, verbose_name='Carga horária'), + ), + ] diff --git a/app/nossas/apps/jobs/migrations/0003_job_status.py b/app/nossas/apps/jobs/migrations/0003_job_status.py new file mode 100644 index 00000000..5832ae41 --- /dev/null +++ b/app/nossas/apps/jobs/migrations/0003_job_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-01-03 14:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jobs', '0002_auto_20240103_1431'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='status', + field=models.CharField(choices=[('opened', 'Aberto'), ('closed', 'Fechado')], default='opened', max_length=20, verbose_name='Status da vaga'), + ), + ] diff --git a/app/nossas/apps/jobs/migrations/0004_job_created_at.py b/app/nossas/apps/jobs/migrations/0004_job_created_at.py new file mode 100644 index 00000000..5a9de6cc --- /dev/null +++ b/app/nossas/apps/jobs/migrations/0004_job_created_at.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2 on 2024-01-03 14:42 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('jobs', '0003_job_status'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='Data de criação'), + preserve_default=False, + ), + ] diff --git a/app/nossas/apps/jobs/migrations/0005_job_picture.py b/app/nossas/apps/jobs/migrations/0005_job_picture.py new file mode 100644 index 00000000..5dbce574 --- /dev/null +++ b/app/nossas/apps/jobs/migrations/0005_job_picture.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2 on 2024-01-03 15:21 + +from django.conf import settings +from django.db import migrations +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ('jobs', '0004_job_created_at'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='picture', + field=filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL, verbose_name='Imagem'), + ), + ] diff --git a/app/nossas/apps/jobs/migrations/__init__.py b/app/nossas/apps/jobs/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/jobs/models.py b/app/nossas/apps/jobs/models.py new file mode 100644 index 00000000..eb122f98 --- /dev/null +++ b/app/nossas/apps/jobs/models.py @@ -0,0 +1,55 @@ +from django.db import models +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ + +from djangocms_text_ckeditor.fields import HTMLField +from filer.fields.image import FilerImageField + +from nossas.apps.basemodel import OnSiteBaseModel + + +class JobStatus(models.TextChoices): + opened = "opened", _("Aberto") + closed = "closed", _("Fechado") + + +class Job(OnSiteBaseModel): + title = models.CharField(_("Título da vaga"), max_length=100) + description = HTMLField( + _("Descrição da vaga"), configuration="CKEDITOR_SETTINGS_JOB_MODEL" + ) + picture = FilerImageField( + verbose_name=_("Imagem"), on_delete=models.SET_NULL, blank=True, null=True + ) + workload = models.CharField(_("Carga horária"), max_length=50) + condition = models.CharField(_("Condições de trabalho"), max_length=50) + responsibilities = HTMLField( + _("Responsabilidades da vaga"), configuration="CKEDITOR_SETTINGS_JOB_MODEL" + ) + prerequisites = HTMLField( + _("Pré-requisitos da vaga"), configuration="CKEDITOR_SETTINGS_JOB_MODEL" + ) + benefits = HTMLField(_("Benefícios"), configuration="CKEDITOR_SETTINGS_JOB_MODEL") + salary_estimate = models.CharField(_("Estimativa salarial"), max_length=80) + + status = models.CharField( + _("Status da vaga"), + max_length=20, + choices=JobStatus.choices, + default=JobStatus.opened, + ) + + created_at = models.DateTimeField( + _("Data de criação"), auto_now_add=True, blank=True + ) + + class Meta: + verbose_name = _("Vaga") + verbose_name_plural = _("Vagas") + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("jobs:job-detail", kwargs={"pk": self.pk}) + \ No newline at end of file diff --git a/app/nossas/apps/jobs/templates/nossas/jobs/job_detail.html b/app/nossas/apps/jobs/templates/nossas/jobs/job_detail.html new file mode 100644 index 00000000..63390a67 --- /dev/null +++ b/app/nossas/apps/jobs/templates/nossas/jobs/job_detail.html @@ -0,0 +1,69 @@ +{% extends 'nossas/home.html' %} +{% load i18n %} + +{% block content %} +{% include "nossas/plugins/navbar.html" with instance=navbar %} +
    + {{ object.title }} +
    +
    +

    {{ object.title }}

    +

    {{ object.description }}

    +
    +
    +
    +

    {% translate "Detalhes da vaga" %}

    +

    Faltando esse nome

    +
    + {% include "nossas/svg/clock.svg" with line_color="#000" %} +
    + {% translate "Carga horária" %} +

    {{ object.workload }}

    +
    +
    +
    + {% include "nossas/svg/alert-circle.svg" with line_color="#000" %} +
    + {% translate "Condições de trabalho" %} +

    {{ object.condition }}

    +
    +
    +
    + {% include "nossas/svg/list.svg" with line_color="#000" %} +
    + {% translate "Responsabilidades da vaga" %} +

    {{ object.responsibilities }}

    +
    +
    +
    + {% include "nossas/svg/alert-triangle.svg" with line_color="#000" %} +
    + {% translate "Pré-requisitos da vaga" %} +

    {{ object.prerequisites }}

    +
    +
    +
    + {% include "nossas/svg/award.svg" with line_color="#000" %} +
    + {% translate "Benefícios" %} +

    {{ object.benefits }}

    +
    +
    +
    + {% include "nossas/svg/dollar-sign.svg" with line_color="#000" %} +
    + {% translate "Estimativa salarial" %} +

    {{ object.salary_estimate }}

    +
    +
    +
    +
    +
    +

    {% translate "Veja outra vaga" %}

    +
    +
    + {% include "nossas/jobs/plugins/slider_jobs_plugin.html" %} +
    +{% include "nossas/plugins/site_footer.html" %} +{% endblock %} + diff --git a/app/nossas/apps/jobs/templates/nossas/jobs/plugins/slider_jobs_item.html b/app/nossas/apps/jobs/templates/nossas/jobs/plugins/slider_jobs_item.html new file mode 100644 index 00000000..2ef1980c --- /dev/null +++ b/app/nossas/apps/jobs/templates/nossas/jobs/plugins/slider_jobs_item.html @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/app/nossas/apps/jobs/templates/nossas/jobs/plugins/slider_jobs_plugin.html b/app/nossas/apps/jobs/templates/nossas/jobs/plugins/slider_jobs_plugin.html new file mode 100644 index 00000000..1b624f9d --- /dev/null +++ b/app/nossas/apps/jobs/templates/nossas/jobs/plugins/slider_jobs_plugin.html @@ -0,0 +1,25 @@ +{% load cms_tags %} +
    + +
    \ No newline at end of file diff --git a/app/nossas/apps/jobs/urls.py b/app/nossas/apps/jobs/urls.py new file mode 100644 index 00000000..203c5335 --- /dev/null +++ b/app/nossas/apps/jobs/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import JobDetailView + + +urlpatterns = [ + path("/", JobDetailView.as_view(), name="job-detail"), +] \ No newline at end of file diff --git a/app/nossas/apps/jobs/views.py b/app/nossas/apps/jobs/views.py new file mode 100644 index 00000000..51c02487 --- /dev/null +++ b/app/nossas/apps/jobs/views.py @@ -0,0 +1,24 @@ +from django.db.models import Q +from django.views.generic import DetailView + +from .models import Job, JobStatus + + +class JobDetailView(DetailView): + model = Job + template_name = "nossas/jobs/job_detail.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + object = context["object"] + context.update( + { + "navbar": {"classes": "bg-verde-nossas"}, + "job_list": Job.on_site.filter(~Q(id=object.pk)).filter( + ~Q(status=JobStatus.closed) + ), + } + ) + + return context diff --git a/app/nossas/apps/team/__init__.py b/app/nossas/apps/team/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/team/admin.py b/app/nossas/apps/team/admin.py new file mode 100644 index 00000000..20e94e59 --- /dev/null +++ b/app/nossas/apps/team/admin.py @@ -0,0 +1,10 @@ +from typing import Any +from django.contrib import admin + +from nossas.apps.baseadmin import OnSiteAdmin + +from .models import MemberGroup, Member + + +admin.site.register(MemberGroup, OnSiteAdmin) +admin.site.register(Member, OnSiteAdmin) diff --git a/app/nossas/apps/team/cms_plugins.py b/app/nossas/apps/team/cms_plugins.py new file mode 100644 index 00000000..bbb07996 --- /dev/null +++ b/app/nossas/apps/team/cms_plugins.py @@ -0,0 +1,18 @@ +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool + +from .models import MemberGroup + + +@plugin_pool.register_plugin +class TeamAccordionPlugin(CMSPluginBase): + name = "Acordeon de Equipe" + module = "NOSSAS" + render_template = "plugins/team_accordion_plugin.html" + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + + context.update({"membergroup_list": MemberGroup.on_site.all()}) + + return context diff --git a/app/nossas/apps/team/migrations/0001_initial.py b/app/nossas/apps/team/migrations/0001_initial.py new file mode 100644 index 00000000..fc29a37d --- /dev/null +++ b/app/nossas/apps/team/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2 on 2023-12-30 15:34 + +from django.conf import settings +import django.contrib.sites.managers +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ('sites', '0002_alter_domain_unique'), + ] + + operations = [ + migrations.CreateModel( + name='MemberGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + managers=[ + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('short_name', models.CharField(blank=True, max_length=80, null=True)), + ('full_name', models.CharField(max_length=150)), + ('short_description', models.CharField(max_length=255)), + ('description', models.TextField()), + ('member_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='team.membergroup')), + ('picture', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.FILER_IMAGE_MODEL)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + managers=[ + ('on_site', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + ] diff --git a/app/nossas/apps/team/migrations/0002_auto_20231230_1551.py b/app/nossas/apps/team/migrations/0002_auto_20231230_1551.py new file mode 100644 index 00000000..87f23cb2 --- /dev/null +++ b/app/nossas/apps/team/migrations/0002_auto_20231230_1551.py @@ -0,0 +1,58 @@ +# Generated by Django 3.2 on 2023-12-30 15:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('team', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='member', + name='description', + ), + migrations.RemoveField( + model_name='member', + name='short_description', + ), + migrations.RemoveField( + model_name='membergroup', + name='name', + ), + migrations.AddField( + model_name='member', + name='description_en', + field=models.TextField(blank=True, verbose_name='description'), + ), + migrations.AddField( + model_name='member', + name='description_pt_br', + field=models.TextField(default='', verbose_name='description'), + preserve_default=False, + ), + migrations.AddField( + model_name='member', + name='short_description_en', + field=models.CharField(blank=True, max_length=255, verbose_name='short_description'), + ), + migrations.AddField( + model_name='member', + name='short_description_pt_br', + field=models.CharField(default='', max_length=255, verbose_name='short_description'), + preserve_default=False, + ), + migrations.AddField( + model_name='membergroup', + name='name_en', + field=models.CharField(blank=True, max_length=120, verbose_name='name'), + ), + migrations.AddField( + model_name='membergroup', + name='name_pt_br', + field=models.CharField(default='', max_length=120, verbose_name='name'), + preserve_default=False, + ), + ] diff --git a/app/nossas/apps/team/migrations/__init__.py b/app/nossas/apps/team/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/apps/team/models.py b/app/nossas/apps/team/models.py new file mode 100644 index 00000000..69d0ac0a --- /dev/null +++ b/app/nossas/apps/team/models.py @@ -0,0 +1,26 @@ +from django.db import models + + +from filer.fields.image import FilerImageField +from translated_fields import TranslatedField +from nossas.apps.basemodel import OnSiteBaseModel + + +class MemberGroup(OnSiteBaseModel): + name = TranslatedField(models.CharField(max_length=120), {"en": {"blank": True}}) + + def __str__(self): + return self.name + + +class Member(OnSiteBaseModel): + picture = FilerImageField(on_delete=models.SET_NULL, blank=True, null=True) + short_name = models.CharField(max_length=80, blank=True, null=True) + full_name = models.CharField(max_length=150) + short_description = TranslatedField( + models.CharField(max_length=255), {"en": {"blank": True}} + ) + description = TranslatedField(models.TextField(), {"en": {"blank": True}}) + member_group = models.ForeignKey( + MemberGroup, on_delete=models.SET_NULL, blank=True, null=True + ) diff --git a/app/nossas/apps/team/templates/plugins/team_accordion_plugin.html b/app/nossas/apps/team/templates/plugins/team_accordion_plugin.html new file mode 100644 index 00000000..f3ba73dd --- /dev/null +++ b/app/nossas/apps/team/templates/plugins/team_accordion_plugin.html @@ -0,0 +1,22 @@ +
    + {% for member_group in membergroup_list %} +
    +

    + +

    +
    +
    + {% for member in member_group.member_set.all %} + {% include 'team/member_modal.html' with instance=member %} + {% endfor %} +
    +
    +
    + {% if not forloop.last %} +
    + {% endif %} + {% endfor %} +
    \ No newline at end of file diff --git a/app/nossas/apps/team/templates/team/member_modal.html b/app/nossas/apps/team/templates/team/member_modal.html new file mode 100644 index 00000000..4a344f4e --- /dev/null +++ b/app/nossas/apps/team/templates/team/member_modal.html @@ -0,0 +1,31 @@ + +
    + + {% include "nossas/graphics/group_30.svg" %} +
    +

    {{ instance.full_name }}

    +

    {{ instance.short_description }}

    +
    +
    + + + \ No newline at end of file diff --git a/app/nossas/design/__init__.py b/app/nossas/design/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/design/cms_plugins.py b/app/nossas/design/cms_plugins.py new file mode 100644 index 00000000..c9351269 --- /dev/null +++ b/app/nossas/design/cms_plugins.py @@ -0,0 +1,43 @@ +from cms.plugin_base import CMSPluginBase + + +class UICMSPluginBase(CMSPluginBase): + change_form_template = "design/admin/ui_cms_plugin_change_form.html" + # custom_fieldsets = {} + + +# class UIPaddingMixin: +# blockname = "Espaçamento" +# blockfields = ("padding_x", "padding_y") + +# def __init__(self, *args, **kwargs): +# self.custom_fieldsets.update({self.blockname: self.blockfields}) + +# super().__init__(*args, **kwargs) + + +# class UIBackgroundMixin: +# blockname = "Fundo" +# blockfields = [ +# "background", +# ] + +# def __init__(self, *args, **kwargs): +# self.custom_fieldsets.update({self.blockname: self.blockfields}) + +# super().__init__(*args, **kwargs) + + +# class UIBorderMixin: +# blockname = "Borda" +# blockfields = [ +# "border_start", +# "border_end", +# "border_top", +# "border_bottom", +# ] + +# def __init__(self, *args, **kwargs): +# self.custom_fieldsets.update({self.blockname: self.blockfields}) + +# super().__init__(*args, **kwargs) diff --git a/app/nossas/design/forms.py b/app/nossas/design/forms.py new file mode 100644 index 00000000..6e6bbe09 --- /dev/null +++ b/app/nossas/design/forms.py @@ -0,0 +1,83 @@ +from django import forms +from django.conf import settings +from django.utils.text import slugify + +from django_jsonform.forms.fields import JSONFormField +from entangled.forms import EntangledModelFormMixin + + +EMPTY_CHOICES = [("", "----")] + +SPACING = ["0", "1", "2", "3", "4", "5", "auto"] + + +class UIPaddingFormMixin(EntangledModelFormMixin): + padding = JSONFormField( + schema={ + "type": "array", + "items": { + "type": "dict", + "keys": { + "side": { + "type": "string", + "choices": [ + {"title": "*-top", "value": "t"}, + {"title": "*-right", "value": "r"}, + {"title": "*-bottom", "value": "b"}, + {"title": "*-left", "value": "l"}, + {"title": "*-left & *-right", "value": "x"}, + {"title": "*-top & *-bottom", "value": "y"}, + ], + }, + "spacing": {"type": "string", "choices": SPACING}, + }, + }, + }, + required=False + ) + + class Meta: + entangled_fields = {"attributes": ["padding"]} + + +if hasattr(settings, "DESIGN_THEME_COLORS"): + CORES_TEMAS = EMPTY_CHOICES + [ + ("bg-" + slugify(args[0]), args[0]) for args in settings.DESIGN_THEME_COLORS + ] +else: + CORES_TEMAS = EMPTY_CHOICES + [ + ("bg-primary", "Primary"), + ("bg-secondary", "Secondary"), + ("bg-green", "Green"), + ("bg-yellow", "Yellow"), + ("bg-pink", "Pink"), + ] + + +class UIBackgroundSelect(forms.RadioSelect): + template_name = "design/fields/background_select.html" + option_template_name = "design/fields/background_select_option.html" + + class Media: + css = {"all": ("djangocms_frontend/css/button_group.css",)} + + +class UIBackgroundFormMixin(EntangledModelFormMixin): + background = forms.ChoiceField( + choices=CORES_TEMAS, required=False, widget=UIBackgroundSelect() + ) + + class Meta: + entangled_fields = {"attributes": ["background"]} + + +class UIBorderFormMixin(EntangledModelFormMixin): + border_start = forms.BooleanField(required=False) + border_end = forms.BooleanField(required=False) + border_top = forms.BooleanField(required=False) + border_bottom = forms.BooleanField(required=False) + + class Meta: + entangled_fields = { + "attributes": ["border_start", "border_end", "border_top", "border_bottom"] + } diff --git a/app/nossas/design/migrations/__init__.py b/app/nossas/design/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/nossas/design/models.py b/app/nossas/design/models.py new file mode 100644 index 00000000..ba3f08de --- /dev/null +++ b/app/nossas/design/models.py @@ -0,0 +1,70 @@ +from django.db import models + +from cms.models.pluginmodel import CMSPlugin + + +class UICMSPlugin(CMSPlugin): + attributes = models.JSONField(null=True, blank=True) + + class Meta: + abstract = True + + def get_classes(self): + return [] + + @property + def classes(self): + return " ".join(self.get_classes()) + + +class UIBackgroundMixin: + def get_classes(self): + classes = super().get_classes() + + bootstrap_classes = dict( + filter( + lambda pair: pair[0] in ["background", ], + self.attributes.items(), + ) + ) + + return classes + list(bootstrap_classes.values()) + + +class UIPaddingMixin: + def get_classes(self): + classes = super().get_classes() + + padding = self.attributes.get("padding") + if padding and len(padding) > 0: + classes += list(map(self.format_padding, padding)) + + return classes + + def format_padding(self, property): + if ( + property["side"] == "x" + and property["spacing"] != "0" + and property["spacing"] != "auto" + ): + return f"p{property['side']}-sm-{property['spacing']} p{property['side']}-{int(property['spacing']) - 1}" + + return f"p{property['side']}-{property['spacing']}" + + +class UIBorderMixin: + def get_classes(self): + classes = super().get_classes() + has_border = False + + for attr in ["border_top", "border_bottom", "border_start", "border_end"]: + if attr in self.attributes.keys() and self.attributes[attr]: + has_border = True + + if not self.attributes.get(attr, True): + classes.append(f"{attr.replace('_', '-')}-0") + + if has_border: + classes = ["border", "border-2", "border-dark"] + classes + + return classes diff --git a/app/nossas/design/static/bootstrap/js/index.esm.js b/app/nossas/design/static/bootstrap/js/index.esm.js new file mode 100644 index 00000000..155d9fb6 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/index.esm.js @@ -0,0 +1,19 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap index.esm.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +export { default as Alert } from './src/alert.js' +export { default as Button } from './src/button.js' +export { default as Carousel } from './src/carousel.js' +export { default as Collapse } from './src/collapse.js' +export { default as Dropdown } from './src/dropdown.js' +export { default as Modal } from './src/modal.js' +export { default as Offcanvas } from './src/offcanvas.js' +export { default as Popover } from './src/popover.js' +export { default as ScrollSpy } from './src/scrollspy.js' +export { default as Tab } from './src/tab.js' +export { default as Toast } from './src/toast.js' +export { default as Tooltip } from './src/tooltip.js' diff --git a/app/nossas/design/static/bootstrap/js/index.umd.js b/app/nossas/design/static/bootstrap/js/index.umd.js new file mode 100644 index 00000000..a33df746 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/index.umd.js @@ -0,0 +1,34 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap index.umd.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import Alert from './src/alert.js' +import Button from './src/button.js' +import Carousel from './src/carousel.js' +import Collapse from './src/collapse.js' +import Dropdown from './src/dropdown.js' +import Modal from './src/modal.js' +import Offcanvas from './src/offcanvas.js' +import Popover from './src/popover.js' +import ScrollSpy from './src/scrollspy.js' +import Tab from './src/tab.js' +import Toast from './src/toast.js' +import Tooltip from './src/tooltip.js' + +export default { + Alert, + Button, + Carousel, + Collapse, + Dropdown, + Modal, + Offcanvas, + Popover, + ScrollSpy, + Tab, + Toast, + Tooltip +} diff --git a/app/nossas/design/static/bootstrap/js/src/alert.js b/app/nossas/design/static/bootstrap/js/src/alert.js new file mode 100644 index 00000000..88232bce --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/alert.js @@ -0,0 +1,87 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap alert.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import { enableDismissTrigger } from './util/component-functions.js' +import { defineJQueryPlugin } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'alert' +const DATA_KEY = 'bs.alert' +const EVENT_KEY = `.${DATA_KEY}` + +const EVENT_CLOSE = `close${EVENT_KEY}` +const EVENT_CLOSED = `closed${EVENT_KEY}` +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' + +/** + * Class definition + */ + +class Alert extends BaseComponent { + // Getters + static get NAME() { + return NAME + } + + // Public + close() { + const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE) + + if (closeEvent.defaultPrevented) { + return + } + + this._element.classList.remove(CLASS_NAME_SHOW) + + const isAnimated = this._element.classList.contains(CLASS_NAME_FADE) + this._queueCallback(() => this._destroyElement(), this._element, isAnimated) + } + + // Private + _destroyElement() { + this._element.remove() + EventHandler.trigger(this._element, EVENT_CLOSED) + this.dispose() + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = Alert.getOrCreateInstance(this) + + if (typeof config !== 'string') { + return + } + + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](this) + }) + } +} + +/** + * Data API implementation + */ + +enableDismissTrigger(Alert, 'close') + +/** + * jQuery + */ + +defineJQueryPlugin(Alert) + +export default Alert diff --git a/app/nossas/design/static/bootstrap/js/src/base-component.js b/app/nossas/design/static/bootstrap/js/src/base-component.js new file mode 100644 index 00000000..85af731e --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/base-component.js @@ -0,0 +1,85 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap base-component.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import Data from './dom/data.js' +import EventHandler from './dom/event-handler.js' +import Config from './util/config.js' +import { executeAfterTransition, getElement } from './util/index.js' + +/** + * Constants + */ + +const VERSION = '5.3.2' + +/** + * Class definition + */ + +class BaseComponent extends Config { + constructor(element, config) { + super() + + element = getElement(element) + if (!element) { + return + } + + this._element = element + this._config = this._getConfig(config) + + Data.set(this._element, this.constructor.DATA_KEY, this) + } + + // Public + dispose() { + Data.remove(this._element, this.constructor.DATA_KEY) + EventHandler.off(this._element, this.constructor.EVENT_KEY) + + for (const propertyName of Object.getOwnPropertyNames(this)) { + this[propertyName] = null + } + } + + _queueCallback(callback, element, isAnimated = true) { + executeAfterTransition(callback, element, isAnimated) + } + + _getConfig(config) { + config = this._mergeConfigObj(config, this._element) + config = this._configAfterMerge(config) + this._typeCheckConfig(config) + return config + } + + // Static + static getInstance(element) { + return Data.get(getElement(element), this.DATA_KEY) + } + + static getOrCreateInstance(element, config = {}) { + return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null) + } + + static get VERSION() { + return VERSION + } + + static get DATA_KEY() { + return `bs.${this.NAME}` + } + + static get EVENT_KEY() { + return `.${this.DATA_KEY}` + } + + static eventName(name) { + return `${name}${this.EVENT_KEY}` + } +} + +export default BaseComponent diff --git a/app/nossas/design/static/bootstrap/js/src/button.js b/app/nossas/design/static/bootstrap/js/src/button.js new file mode 100644 index 00000000..a797f505 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/button.js @@ -0,0 +1,72 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap button.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import { defineJQueryPlugin } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'button' +const DATA_KEY = 'bs.button' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const CLASS_NAME_ACTIVE = 'active' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="button"]' +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` + +/** + * Class definition + */ + +class Button extends BaseComponent { + // Getters + static get NAME() { + return NAME + } + + // Public + toggle() { + // Toggle class and sync the `aria-pressed` attribute with the return value of the `.toggle()` method + this._element.setAttribute('aria-pressed', this._element.classList.toggle(CLASS_NAME_ACTIVE)) + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = Button.getOrCreateInstance(this) + + if (config === 'toggle') { + data[config]() + } + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, event => { + event.preventDefault() + + const button = event.target.closest(SELECTOR_DATA_TOGGLE) + const data = Button.getOrCreateInstance(button) + + data.toggle() +}) + +/** + * jQuery + */ + +defineJQueryPlugin(Button) + +export default Button diff --git a/app/nossas/design/static/bootstrap/js/src/carousel.js b/app/nossas/design/static/bootstrap/js/src/carousel.js new file mode 100644 index 00000000..68d11a32 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/carousel.js @@ -0,0 +1,474 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap carousel.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import Manipulator from './dom/manipulator.js' +import SelectorEngine from './dom/selector-engine.js' +import { + defineJQueryPlugin, + getNextActiveElement, + isRTL, + isVisible, + reflow, + triggerTransitionEnd +} from './util/index.js' +import Swipe from './util/swipe.js' + +/** + * Constants + */ + +const NAME = 'carousel' +const DATA_KEY = 'bs.carousel' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const ARROW_LEFT_KEY = 'ArrowLeft' +const ARROW_RIGHT_KEY = 'ArrowRight' +const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch + +const ORDER_NEXT = 'next' +const ORDER_PREV = 'prev' +const DIRECTION_LEFT = 'left' +const DIRECTION_RIGHT = 'right' + +const EVENT_SLIDE = `slide${EVENT_KEY}` +const EVENT_SLID = `slid${EVENT_KEY}` +const EVENT_KEYDOWN = `keydown${EVENT_KEY}` +const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}` +const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}` +const EVENT_DRAG_START = `dragstart${EVENT_KEY}` +const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_CAROUSEL = 'carousel' +const CLASS_NAME_ACTIVE = 'active' +const CLASS_NAME_SLIDE = 'slide' +const CLASS_NAME_END = 'carousel-item-end' +const CLASS_NAME_START = 'carousel-item-start' +const CLASS_NAME_NEXT = 'carousel-item-next' +const CLASS_NAME_PREV = 'carousel-item-prev' + +const SELECTOR_ACTIVE = '.active' +const SELECTOR_ITEM = '.carousel-item' +const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM +const SELECTOR_ITEM_IMG = '.carousel-item img' +const SELECTOR_INDICATORS = '.carousel-indicators' +const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to]' +const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"]' + +const KEY_TO_DIRECTION = { + [ARROW_LEFT_KEY]: DIRECTION_RIGHT, + [ARROW_RIGHT_KEY]: DIRECTION_LEFT +} + +const Default = { + interval: 5000, + keyboard: true, + pause: 'hover', + ride: false, + touch: true, + wrap: true +} + +const DefaultType = { + interval: '(number|boolean)', // TODO:v6 remove boolean support + keyboard: 'boolean', + pause: '(string|boolean)', + ride: '(boolean|string)', + touch: 'boolean', + wrap: 'boolean' +} + +/** + * Class definition + */ + +class Carousel extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._interval = null + this._activeElement = null + this._isSliding = false + this.touchTimeout = null + this._swipeHelper = null + + this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element) + this._addEventListeners() + + if (this._config.ride === CLASS_NAME_CAROUSEL) { + this.cycle() + } + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + next() { + this._slide(ORDER_NEXT) + } + + nextWhenVisible() { + // FIXME TODO use `document.visibilityState` + // Don't call next when the page isn't visible + // or the carousel or its parent isn't visible + if (!document.hidden && isVisible(this._element)) { + this.next() + } + } + + prev() { + this._slide(ORDER_PREV) + } + + pause() { + if (this._isSliding) { + triggerTransitionEnd(this._element) + } + + this._clearInterval() + } + + cycle() { + this._clearInterval() + this._updateInterval() + + this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval) + } + + _maybeEnableCycle() { + if (!this._config.ride) { + return + } + + if (this._isSliding) { + EventHandler.one(this._element, EVENT_SLID, () => this.cycle()) + return + } + + this.cycle() + } + + to(index) { + const items = this._getItems() + if (index > items.length - 1 || index < 0) { + return + } + + if (this._isSliding) { + EventHandler.one(this._element, EVENT_SLID, () => this.to(index)) + return + } + + const activeIndex = this._getItemIndex(this._getActive()) + if (activeIndex === index) { + return + } + + const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV + + this._slide(order, items[index]) + } + + dispose() { + if (this._swipeHelper) { + this._swipeHelper.dispose() + } + + super.dispose() + } + + // Private + _configAfterMerge(config) { + config.defaultInterval = config.interval + return config + } + + _addEventListeners() { + if (this._config.keyboard) { + EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event)) + } + + if (this._config.pause === 'hover') { + EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause()) + EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle()) + } + + if (this._config.touch && Swipe.isSupported()) { + this._addTouchEventListeners() + } + } + + _addTouchEventListeners() { + for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) { + EventHandler.on(img, EVENT_DRAG_START, event => event.preventDefault()) + } + + const endCallBack = () => { + if (this._config.pause !== 'hover') { + return + } + + // If it's a touch-enabled device, mouseenter/leave are fired as + // part of the mouse compatibility events on first tap - the carousel + // would stop cycling until user tapped out of it; + // here, we listen for touchend, explicitly pause the carousel + // (as if it's the second time we tap on it, mouseenter compat event + // is NOT fired) and after a timeout (to allow for mouse compatibility + // events to fire) we explicitly restart cycling + + this.pause() + if (this.touchTimeout) { + clearTimeout(this.touchTimeout) + } + + this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + this._config.interval) + } + + const swipeConfig = { + leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)), + rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)), + endCallback: endCallBack + } + + this._swipeHelper = new Swipe(this._element, swipeConfig) + } + + _keydown(event) { + if (/input|textarea/i.test(event.target.tagName)) { + return + } + + const direction = KEY_TO_DIRECTION[event.key] + if (direction) { + event.preventDefault() + this._slide(this._directionToOrder(direction)) + } + } + + _getItemIndex(element) { + return this._getItems().indexOf(element) + } + + _setActiveIndicatorElement(index) { + if (!this._indicatorsElement) { + return + } + + const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement) + + activeIndicator.classList.remove(CLASS_NAME_ACTIVE) + activeIndicator.removeAttribute('aria-current') + + const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"]`, this._indicatorsElement) + + if (newActiveIndicator) { + newActiveIndicator.classList.add(CLASS_NAME_ACTIVE) + newActiveIndicator.setAttribute('aria-current', 'true') + } + } + + _updateInterval() { + const element = this._activeElement || this._getActive() + + if (!element) { + return + } + + const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval'), 10) + + this._config.interval = elementInterval || this._config.defaultInterval + } + + _slide(order, element = null) { + if (this._isSliding) { + return + } + + const activeElement = this._getActive() + const isNext = order === ORDER_NEXT + const nextElement = element || getNextActiveElement(this._getItems(), activeElement, isNext, this._config.wrap) + + if (nextElement === activeElement) { + return + } + + const nextElementIndex = this._getItemIndex(nextElement) + + const triggerEvent = eventName => { + return EventHandler.trigger(this._element, eventName, { + relatedTarget: nextElement, + direction: this._orderToDirection(order), + from: this._getItemIndex(activeElement), + to: nextElementIndex + }) + } + + const slideEvent = triggerEvent(EVENT_SLIDE) + + if (slideEvent.defaultPrevented) { + return + } + + if (!activeElement || !nextElement) { + // Some weirdness is happening, so we bail + // TODO: change tests that use empty divs to avoid this check + return + } + + const isCycling = Boolean(this._interval) + this.pause() + + this._isSliding = true + + this._setActiveIndicatorElement(nextElementIndex) + this._activeElement = nextElement + + const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END + const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV + + nextElement.classList.add(orderClassName) + + reflow(nextElement) + + activeElement.classList.add(directionalClassName) + nextElement.classList.add(directionalClassName) + + const completeCallBack = () => { + nextElement.classList.remove(directionalClassName, orderClassName) + nextElement.classList.add(CLASS_NAME_ACTIVE) + + activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName) + + this._isSliding = false + + triggerEvent(EVENT_SLID) + } + + this._queueCallback(completeCallBack, activeElement, this._isAnimated()) + + if (isCycling) { + this.cycle() + } + } + + _isAnimated() { + return this._element.classList.contains(CLASS_NAME_SLIDE) + } + + _getActive() { + return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element) + } + + _getItems() { + return SelectorEngine.find(SELECTOR_ITEM, this._element) + } + + _clearInterval() { + if (this._interval) { + clearInterval(this._interval) + this._interval = null + } + } + + _directionToOrder(direction) { + if (isRTL()) { + return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT + } + + return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV + } + + _orderToDirection(order) { + if (isRTL()) { + return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT + } + + return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = Carousel.getOrCreateInstance(this, config) + + if (typeof config === 'number') { + data.to(config) + return + } + + if (typeof config === 'string') { + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) + } + + data[config]() + } + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (event) { + const target = SelectorEngine.getElementFromSelector(this) + + if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) { + return + } + + event.preventDefault() + + const carousel = Carousel.getOrCreateInstance(target) + const slideIndex = this.getAttribute('data-bs-slide-to') + + if (slideIndex) { + carousel.to(slideIndex) + carousel._maybeEnableCycle() + return + } + + if (Manipulator.getDataAttribute(this, 'slide') === 'next') { + carousel.next() + carousel._maybeEnableCycle() + return + } + + carousel.prev() + carousel._maybeEnableCycle() +}) + +EventHandler.on(window, EVENT_LOAD_DATA_API, () => { + const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE) + + for (const carousel of carousels) { + Carousel.getOrCreateInstance(carousel) + } +}) + +/** + * jQuery + */ + +defineJQueryPlugin(Carousel) + +export default Carousel diff --git a/app/nossas/design/static/bootstrap/js/src/collapse.js b/app/nossas/design/static/bootstrap/js/src/collapse.js new file mode 100644 index 00000000..9f0c60cc --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/collapse.js @@ -0,0 +1,297 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap collapse.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' +import { + defineJQueryPlugin, + getElement, + reflow +} from './util/index.js' + +/** + * Constants + */ + +const NAME = 'collapse' +const DATA_KEY = 'bs.collapse' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_COLLAPSE = 'collapse' +const CLASS_NAME_COLLAPSING = 'collapsing' +const CLASS_NAME_COLLAPSED = 'collapsed' +const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}` +const CLASS_NAME_HORIZONTAL = 'collapse-horizontal' + +const WIDTH = 'width' +const HEIGHT = 'height' + +const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"]' + +const Default = { + parent: null, + toggle: true +} + +const DefaultType = { + parent: '(null|element)', + toggle: 'boolean' +} + +/** + * Class definition + */ + +class Collapse extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._isTransitioning = false + this._triggerArray = [] + + const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE) + + for (const elem of toggleList) { + const selector = SelectorEngine.getSelectorFromElement(elem) + const filterElement = SelectorEngine.find(selector) + .filter(foundElement => foundElement === this._element) + + if (selector !== null && filterElement.length) { + this._triggerArray.push(elem) + } + } + + this._initializeChildren() + + if (!this._config.parent) { + this._addAriaAndCollapsedClass(this._triggerArray, this._isShown()) + } + + if (this._config.toggle) { + this.toggle() + } + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle() { + if (this._isShown()) { + this.hide() + } else { + this.show() + } + } + + show() { + if (this._isTransitioning || this._isShown()) { + return + } + + let activeChildren = [] + + // find active children + if (this._config.parent) { + activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES) + .filter(element => element !== this._element) + .map(element => Collapse.getOrCreateInstance(element, { toggle: false })) + } + + if (activeChildren.length && activeChildren[0]._isTransitioning) { + return + } + + const startEvent = EventHandler.trigger(this._element, EVENT_SHOW) + if (startEvent.defaultPrevented) { + return + } + + for (const activeInstance of activeChildren) { + activeInstance.hide() + } + + const dimension = this._getDimension() + + this._element.classList.remove(CLASS_NAME_COLLAPSE) + this._element.classList.add(CLASS_NAME_COLLAPSING) + + this._element.style[dimension] = 0 + + this._addAriaAndCollapsedClass(this._triggerArray, true) + this._isTransitioning = true + + const complete = () => { + this._isTransitioning = false + + this._element.classList.remove(CLASS_NAME_COLLAPSING) + this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW) + + this._element.style[dimension] = '' + + EventHandler.trigger(this._element, EVENT_SHOWN) + } + + const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1) + const scrollSize = `scroll${capitalizedDimension}` + + this._queueCallback(complete, this._element, true) + this._element.style[dimension] = `${this._element[scrollSize]}px` + } + + hide() { + if (this._isTransitioning || !this._isShown()) { + return + } + + const startEvent = EventHandler.trigger(this._element, EVENT_HIDE) + if (startEvent.defaultPrevented) { + return + } + + const dimension = this._getDimension() + + this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px` + + reflow(this._element) + + this._element.classList.add(CLASS_NAME_COLLAPSING) + this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW) + + for (const trigger of this._triggerArray) { + const element = SelectorEngine.getElementFromSelector(trigger) + + if (element && !this._isShown(element)) { + this._addAriaAndCollapsedClass([trigger], false) + } + } + + this._isTransitioning = true + + const complete = () => { + this._isTransitioning = false + this._element.classList.remove(CLASS_NAME_COLLAPSING) + this._element.classList.add(CLASS_NAME_COLLAPSE) + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + this._element.style[dimension] = '' + + this._queueCallback(complete, this._element, true) + } + + _isShown(element = this._element) { + return element.classList.contains(CLASS_NAME_SHOW) + } + + // Private + _configAfterMerge(config) { + config.toggle = Boolean(config.toggle) // Coerce string values + config.parent = getElement(config.parent) + return config + } + + _getDimension() { + return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT + } + + _initializeChildren() { + if (!this._config.parent) { + return + } + + const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE) + + for (const element of children) { + const selected = SelectorEngine.getElementFromSelector(element) + + if (selected) { + this._addAriaAndCollapsedClass([element], this._isShown(selected)) + } + } + } + + _getFirstLevelChildren(selector) { + const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent) + // remove children if greater depth + return SelectorEngine.find(selector, this._config.parent).filter(element => !children.includes(element)) + } + + _addAriaAndCollapsedClass(triggerArray, isOpen) { + if (!triggerArray.length) { + return + } + + for (const element of triggerArray) { + element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen) + element.setAttribute('aria-expanded', isOpen) + } + } + + // Static + static jQueryInterface(config) { + const _config = {} + if (typeof config === 'string' && /show|hide/.test(config)) { + _config.toggle = false + } + + return this.each(function () { + const data = Collapse.getOrCreateInstance(this, _config) + + if (typeof config === 'string') { + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config]() + } + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + // preventDefault only for elements (which change the URL) not inside the collapsible element + if (event.target.tagName === 'A' || (event.delegateTarget && event.delegateTarget.tagName === 'A')) { + event.preventDefault() + } + + for (const element of SelectorEngine.getMultipleElementsFromSelector(this)) { + Collapse.getOrCreateInstance(element, { toggle: false }).toggle() + } +}) + +/** + * jQuery + */ + +defineJQueryPlugin(Collapse) + +export default Collapse diff --git a/app/nossas/design/static/bootstrap/js/src/dom/data.js b/app/nossas/design/static/bootstrap/js/src/dom/data.js new file mode 100644 index 00000000..407f67e3 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/dom/data.js @@ -0,0 +1,55 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap dom/data.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +/** + * Constants + */ + +const elementMap = new Map() + +export default { + set(element, key, instance) { + if (!elementMap.has(element)) { + elementMap.set(element, new Map()) + } + + const instanceMap = elementMap.get(element) + + // make it clear we only want one instance per element + // can be removed later when multiple key/instances are fine to be used + if (!instanceMap.has(key) && instanceMap.size !== 0) { + // eslint-disable-next-line no-console + console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`) + return + } + + instanceMap.set(key, instance) + }, + + get(element, key) { + if (elementMap.has(element)) { + return elementMap.get(element).get(key) || null + } + + return null + }, + + remove(element, key) { + if (!elementMap.has(element)) { + return + } + + const instanceMap = elementMap.get(element) + + instanceMap.delete(key) + + // free up element references if there are no instances left for an element + if (instanceMap.size === 0) { + elementMap.delete(element) + } + } +} diff --git a/app/nossas/design/static/bootstrap/js/src/dom/event-handler.js b/app/nossas/design/static/bootstrap/js/src/dom/event-handler.js new file mode 100644 index 00000000..561d8751 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/dom/event-handler.js @@ -0,0 +1,317 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap dom/event-handler.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { getjQuery } from '../util/index.js' + +/** + * Constants + */ + +const namespaceRegex = /[^.]*(?=\..*)\.|.*/ +const stripNameRegex = /\..*/ +const stripUidRegex = /::\d+$/ +const eventRegistry = {} // Events storage +let uidEvent = 1 +const customEvents = { + mouseenter: 'mouseover', + mouseleave: 'mouseout' +} + +const nativeEvents = new Set([ + 'click', + 'dblclick', + 'mouseup', + 'mousedown', + 'contextmenu', + 'mousewheel', + 'DOMMouseScroll', + 'mouseover', + 'mouseout', + 'mousemove', + 'selectstart', + 'selectend', + 'keydown', + 'keypress', + 'keyup', + 'orientationchange', + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', + 'pointerdown', + 'pointermove', + 'pointerup', + 'pointerleave', + 'pointercancel', + 'gesturestart', + 'gesturechange', + 'gestureend', + 'focus', + 'blur', + 'change', + 'reset', + 'select', + 'submit', + 'focusin', + 'focusout', + 'load', + 'unload', + 'beforeunload', + 'resize', + 'move', + 'DOMContentLoaded', + 'readystatechange', + 'error', + 'abort', + 'scroll' +]) + +/** + * Private methods + */ + +function makeEventUid(element, uid) { + return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++ +} + +function getElementEvents(element) { + const uid = makeEventUid(element) + + element.uidEvent = uid + eventRegistry[uid] = eventRegistry[uid] || {} + + return eventRegistry[uid] +} + +function bootstrapHandler(element, fn) { + return function handler(event) { + hydrateObj(event, { delegateTarget: element }) + + if (handler.oneOff) { + EventHandler.off(element, event.type, fn) + } + + return fn.apply(element, [event]) + } +} + +function bootstrapDelegationHandler(element, selector, fn) { + return function handler(event) { + const domElements = element.querySelectorAll(selector) + + for (let { target } = event; target && target !== this; target = target.parentNode) { + for (const domElement of domElements) { + if (domElement !== target) { + continue + } + + hydrateObj(event, { delegateTarget: target }) + + if (handler.oneOff) { + EventHandler.off(element, event.type, selector, fn) + } + + return fn.apply(target, [event]) + } + } + } +} + +function findHandler(events, callable, delegationSelector = null) { + return Object.values(events) + .find(event => event.callable === callable && event.delegationSelector === delegationSelector) +} + +function normalizeParameters(originalTypeEvent, handler, delegationFunction) { + const isDelegated = typeof handler === 'string' + // TODO: tooltip passes `false` instead of selector, so we need to check + const callable = isDelegated ? delegationFunction : (handler || delegationFunction) + let typeEvent = getTypeEvent(originalTypeEvent) + + if (!nativeEvents.has(typeEvent)) { + typeEvent = originalTypeEvent + } + + return [isDelegated, callable, typeEvent] +} + +function addHandler(element, originalTypeEvent, handler, delegationFunction, oneOff) { + if (typeof originalTypeEvent !== 'string' || !element) { + return + } + + let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction) + + // in case of mouseenter or mouseleave wrap the handler within a function that checks for its DOM position + // this prevents the handler from being dispatched the same way as mouseover or mouseout does + if (originalTypeEvent in customEvents) { + const wrapFunction = fn => { + return function (event) { + if (!event.relatedTarget || (event.relatedTarget !== event.delegateTarget && !event.delegateTarget.contains(event.relatedTarget))) { + return fn.call(this, event) + } + } + } + + callable = wrapFunction(callable) + } + + const events = getElementEvents(element) + const handlers = events[typeEvent] || (events[typeEvent] = {}) + const previousFunction = findHandler(handlers, callable, isDelegated ? handler : null) + + if (previousFunction) { + previousFunction.oneOff = previousFunction.oneOff && oneOff + + return + } + + const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, '')) + const fn = isDelegated ? + bootstrapDelegationHandler(element, handler, callable) : + bootstrapHandler(element, callable) + + fn.delegationSelector = isDelegated ? handler : null + fn.callable = callable + fn.oneOff = oneOff + fn.uidEvent = uid + handlers[uid] = fn + + element.addEventListener(typeEvent, fn, isDelegated) +} + +function removeHandler(element, events, typeEvent, handler, delegationSelector) { + const fn = findHandler(events[typeEvent], handler, delegationSelector) + + if (!fn) { + return + } + + element.removeEventListener(typeEvent, fn, Boolean(delegationSelector)) + delete events[typeEvent][fn.uidEvent] +} + +function removeNamespacedHandlers(element, events, typeEvent, namespace) { + const storeElementEvent = events[typeEvent] || {} + + for (const [handlerKey, event] of Object.entries(storeElementEvent)) { + if (handlerKey.includes(namespace)) { + removeHandler(element, events, typeEvent, event.callable, event.delegationSelector) + } + } +} + +function getTypeEvent(event) { + // allow to get the native events from namespaced events ('click.bs.button' --> 'click') + event = event.replace(stripNameRegex, '') + return customEvents[event] || event +} + +const EventHandler = { + on(element, event, handler, delegationFunction) { + addHandler(element, event, handler, delegationFunction, false) + }, + + one(element, event, handler, delegationFunction) { + addHandler(element, event, handler, delegationFunction, true) + }, + + off(element, originalTypeEvent, handler, delegationFunction) { + if (typeof originalTypeEvent !== 'string' || !element) { + return + } + + const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction) + const inNamespace = typeEvent !== originalTypeEvent + const events = getElementEvents(element) + const storeElementEvent = events[typeEvent] || {} + const isNamespace = originalTypeEvent.startsWith('.') + + if (typeof callable !== 'undefined') { + // Simplest case: handler is passed, remove that listener ONLY. + if (!Object.keys(storeElementEvent).length) { + return + } + + removeHandler(element, events, typeEvent, callable, isDelegated ? handler : null) + return + } + + if (isNamespace) { + for (const elementEvent of Object.keys(events)) { + removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1)) + } + } + + for (const [keyHandlers, event] of Object.entries(storeElementEvent)) { + const handlerKey = keyHandlers.replace(stripUidRegex, '') + + if (!inNamespace || originalTypeEvent.includes(handlerKey)) { + removeHandler(element, events, typeEvent, event.callable, event.delegationSelector) + } + } + }, + + trigger(element, event, args) { + if (typeof event !== 'string' || !element) { + return null + } + + const $ = getjQuery() + const typeEvent = getTypeEvent(event) + const inNamespace = event !== typeEvent + + let jQueryEvent = null + let bubbles = true + let nativeDispatch = true + let defaultPrevented = false + + if (inNamespace && $) { + jQueryEvent = $.Event(event, args) + + $(element).trigger(jQueryEvent) + bubbles = !jQueryEvent.isPropagationStopped() + nativeDispatch = !jQueryEvent.isImmediatePropagationStopped() + defaultPrevented = jQueryEvent.isDefaultPrevented() + } + + const evt = hydrateObj(new Event(event, { bubbles, cancelable: true }), args) + + if (defaultPrevented) { + evt.preventDefault() + } + + if (nativeDispatch) { + element.dispatchEvent(evt) + } + + if (evt.defaultPrevented && jQueryEvent) { + jQueryEvent.preventDefault() + } + + return evt + } +} + +function hydrateObj(obj, meta = {}) { + for (const [key, value] of Object.entries(meta)) { + try { + obj[key] = value + } catch { + Object.defineProperty(obj, key, { + configurable: true, + get() { + return value + } + }) + } + } + + return obj +} + +export default EventHandler diff --git a/app/nossas/design/static/bootstrap/js/src/dom/manipulator.js b/app/nossas/design/static/bootstrap/js/src/dom/manipulator.js new file mode 100644 index 00000000..dd86a9ff --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/dom/manipulator.js @@ -0,0 +1,71 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap dom/manipulator.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +function normalizeData(value) { + if (value === 'true') { + return true + } + + if (value === 'false') { + return false + } + + if (value === Number(value).toString()) { + return Number(value) + } + + if (value === '' || value === 'null') { + return null + } + + if (typeof value !== 'string') { + return value + } + + try { + return JSON.parse(decodeURIComponent(value)) + } catch { + return value + } +} + +function normalizeDataKey(key) { + return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`) +} + +const Manipulator = { + setDataAttribute(element, key, value) { + element.setAttribute(`data-bs-${normalizeDataKey(key)}`, value) + }, + + removeDataAttribute(element, key) { + element.removeAttribute(`data-bs-${normalizeDataKey(key)}`) + }, + + getDataAttributes(element) { + if (!element) { + return {} + } + + const attributes = {} + const bsKeys = Object.keys(element.dataset).filter(key => key.startsWith('bs') && !key.startsWith('bsConfig')) + + for (const key of bsKeys) { + let pureKey = key.replace(/^bs/, '') + pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1, pureKey.length) + attributes[pureKey] = normalizeData(element.dataset[key]) + } + + return attributes + }, + + getDataAttribute(element, key) { + return normalizeData(element.getAttribute(`data-bs-${normalizeDataKey(key)}`)) + } +} + +export default Manipulator diff --git a/app/nossas/design/static/bootstrap/js/src/dom/selector-engine.js b/app/nossas/design/static/bootstrap/js/src/dom/selector-engine.js new file mode 100644 index 00000000..a47f7200 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/dom/selector-engine.js @@ -0,0 +1,126 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap dom/selector-engine.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import { isDisabled, isVisible, parseSelector } from '../util/index.js' + +const getSelector = element => { + let selector = element.getAttribute('data-bs-target') + + if (!selector || selector === '#') { + let hrefAttribute = element.getAttribute('href') + + // The only valid content that could double as a selector are IDs or classes, + // so everything starting with `#` or `.`. If a "real" URL is used as the selector, + // `document.querySelector` will rightfully complain it is invalid. + // See https://github.com/twbs/bootstrap/issues/32273 + if (!hrefAttribute || (!hrefAttribute.includes('#') && !hrefAttribute.startsWith('.'))) { + return null + } + + // Just in case some CMS puts out a full URL with the anchor appended + if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) { + hrefAttribute = `#${hrefAttribute.split('#')[1]}` + } + + selector = hrefAttribute && hrefAttribute !== '#' ? parseSelector(hrefAttribute.trim()) : null + } + + return selector +} + +const SelectorEngine = { + find(selector, element = document.documentElement) { + return [].concat(...Element.prototype.querySelectorAll.call(element, selector)) + }, + + findOne(selector, element = document.documentElement) { + return Element.prototype.querySelector.call(element, selector) + }, + + children(element, selector) { + return [].concat(...element.children).filter(child => child.matches(selector)) + }, + + parents(element, selector) { + const parents = [] + let ancestor = element.parentNode.closest(selector) + + while (ancestor) { + parents.push(ancestor) + ancestor = ancestor.parentNode.closest(selector) + } + + return parents + }, + + prev(element, selector) { + let previous = element.previousElementSibling + + while (previous) { + if (previous.matches(selector)) { + return [previous] + } + + previous = previous.previousElementSibling + } + + return [] + }, + // TODO: this is now unused; remove later along with prev() + next(element, selector) { + let next = element.nextElementSibling + + while (next) { + if (next.matches(selector)) { + return [next] + } + + next = next.nextElementSibling + } + + return [] + }, + + focusableChildren(element) { + const focusables = [ + 'a', + 'button', + 'input', + 'textarea', + 'select', + 'details', + '[tabindex]', + '[contenteditable="true"]' + ].map(selector => `${selector}:not([tabindex^="-"])`).join(',') + + return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el)) + }, + + getSelectorFromElement(element) { + const selector = getSelector(element) + + if (selector) { + return SelectorEngine.findOne(selector) ? selector : null + } + + return null + }, + + getElementFromSelector(element) { + const selector = getSelector(element) + + return selector ? SelectorEngine.findOne(selector) : null + }, + + getMultipleElementsFromSelector(element) { + const selector = getSelector(element) + + return selector ? SelectorEngine.find(selector) : [] + } +} + +export default SelectorEngine diff --git a/app/nossas/design/static/bootstrap/js/src/dropdown.js b/app/nossas/design/static/bootstrap/js/src/dropdown.js new file mode 100644 index 00000000..af5fd16f --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/dropdown.js @@ -0,0 +1,455 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap dropdown.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import * as Popper from '@popperjs/core' +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import Manipulator from './dom/manipulator.js' +import SelectorEngine from './dom/selector-engine.js' +import { + defineJQueryPlugin, + execute, + getElement, + getNextActiveElement, + isDisabled, + isElement, + isRTL, + isVisible, + noop +} from './util/index.js' + +/** + * Constants + */ + +const NAME = 'dropdown' +const DATA_KEY = 'bs.dropdown' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const ESCAPE_KEY = 'Escape' +const TAB_KEY = 'Tab' +const ARROW_UP_KEY = 'ArrowUp' +const ARROW_DOWN_KEY = 'ArrowDown' +const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button + +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}` +const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_DROPUP = 'dropup' +const CLASS_NAME_DROPEND = 'dropend' +const CLASS_NAME_DROPSTART = 'dropstart' +const CLASS_NAME_DROPUP_CENTER = 'dropup-center' +const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center' + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)' +const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}` +const SELECTOR_MENU = '.dropdown-menu' +const SELECTOR_NAVBAR = '.navbar' +const SELECTOR_NAVBAR_NAV = '.navbar-nav' +const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)' + +const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start' +const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end' +const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start' +const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end' +const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start' +const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start' +const PLACEMENT_TOPCENTER = 'top' +const PLACEMENT_BOTTOMCENTER = 'bottom' + +const Default = { + autoClose: true, + boundary: 'clippingParents', + display: 'dynamic', + offset: [0, 2], + popperConfig: null, + reference: 'toggle' +} + +const DefaultType = { + autoClose: '(boolean|string)', + boundary: '(string|element)', + display: 'string', + offset: '(array|string|function)', + popperConfig: '(null|object|function)', + reference: '(string|element|object)' +} + +/** + * Class definition + */ + +class Dropdown extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._popper = null + this._parent = this._element.parentNode // dropdown wrapper + // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ + this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] || + SelectorEngine.prev(this._element, SELECTOR_MENU)[0] || + SelectorEngine.findOne(SELECTOR_MENU, this._parent) + this._inNavbar = this._detectNavbar() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle() { + return this._isShown() ? this.hide() : this.show() + } + + show() { + if (isDisabled(this._element) || this._isShown()) { + return + } + + const relatedTarget = { + relatedTarget: this._element + } + + const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget) + + if (showEvent.defaultPrevented) { + return + } + + this._createPopper() + + // If this is a touch-enabled device we add extra + // empty mouseover listeners to the body's immediate children; + // only needed because of broken event delegation on iOS + // https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html + if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) { + for (const element of [].concat(...document.body.children)) { + EventHandler.on(element, 'mouseover', noop) + } + } + + this._element.focus() + this._element.setAttribute('aria-expanded', true) + + this._menu.classList.add(CLASS_NAME_SHOW) + this._element.classList.add(CLASS_NAME_SHOW) + EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget) + } + + hide() { + if (isDisabled(this._element) || !this._isShown()) { + return + } + + const relatedTarget = { + relatedTarget: this._element + } + + this._completeHide(relatedTarget) + } + + dispose() { + if (this._popper) { + this._popper.destroy() + } + + super.dispose() + } + + update() { + this._inNavbar = this._detectNavbar() + if (this._popper) { + this._popper.update() + } + } + + // Private + _completeHide(relatedTarget) { + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget) + if (hideEvent.defaultPrevented) { + return + } + + // If this is a touch-enabled device we remove the extra + // empty mouseover listeners we added for iOS support + if ('ontouchstart' in document.documentElement) { + for (const element of [].concat(...document.body.children)) { + EventHandler.off(element, 'mouseover', noop) + } + } + + if (this._popper) { + this._popper.destroy() + } + + this._menu.classList.remove(CLASS_NAME_SHOW) + this._element.classList.remove(CLASS_NAME_SHOW) + this._element.setAttribute('aria-expanded', 'false') + Manipulator.removeDataAttribute(this._menu, 'popper') + EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget) + } + + _getConfig(config) { + config = super._getConfig(config) + + if (typeof config.reference === 'object' && !isElement(config.reference) && + typeof config.reference.getBoundingClientRect !== 'function' + ) { + // Popper virtual elements require a getBoundingClientRect method + throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`) + } + + return config + } + + _createPopper() { + if (typeof Popper === 'undefined') { + throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)') + } + + let referenceElement = this._element + + if (this._config.reference === 'parent') { + referenceElement = this._parent + } else if (isElement(this._config.reference)) { + referenceElement = getElement(this._config.reference) + } else if (typeof this._config.reference === 'object') { + referenceElement = this._config.reference + } + + const popperConfig = this._getPopperConfig() + this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig) + } + + _isShown() { + return this._menu.classList.contains(CLASS_NAME_SHOW) + } + + _getPlacement() { + const parentDropdown = this._parent + + if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) { + return PLACEMENT_RIGHT + } + + if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) { + return PLACEMENT_LEFT + } + + if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) { + return PLACEMENT_TOPCENTER + } + + if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) { + return PLACEMENT_BOTTOMCENTER + } + + // We need to trim the value because custom properties can also include spaces + const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end' + + if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) { + return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP + } + + return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM + } + + _detectNavbar() { + return this._element.closest(SELECTOR_NAVBAR) !== null + } + + _getOffset() { + const { offset } = this._config + + if (typeof offset === 'string') { + return offset.split(',').map(value => Number.parseInt(value, 10)) + } + + if (typeof offset === 'function') { + return popperData => offset(popperData, this._element) + } + + return offset + } + + _getPopperConfig() { + const defaultBsPopperConfig = { + placement: this._getPlacement(), + modifiers: [{ + name: 'preventOverflow', + options: { + boundary: this._config.boundary + } + }, + { + name: 'offset', + options: { + offset: this._getOffset() + } + }] + } + + // Disable Popper if we have a static display or Dropdown is in Navbar + if (this._inNavbar || this._config.display === 'static') { + Manipulator.setDataAttribute(this._menu, 'popper', 'static') // TODO: v6 remove + defaultBsPopperConfig.modifiers = [{ + name: 'applyStyles', + enabled: false + }] + } + + return { + ...defaultBsPopperConfig, + ...execute(this._config.popperConfig, [defaultBsPopperConfig]) + } + } + + _selectMenuItem({ key, target }) { + const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element)) + + if (!items.length) { + return + } + + // if target isn't included in items (e.g. when expanding the dropdown) + // allow cycling to get the last item in case key equals ARROW_UP_KEY + getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus() + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = Dropdown.getOrCreateInstance(this, config) + + if (typeof config !== 'string') { + return + } + + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config]() + }) + } + + static clearMenus(event) { + if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) { + return + } + + const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN) + + for (const toggle of openToggles) { + const context = Dropdown.getInstance(toggle) + if (!context || context._config.autoClose === false) { + continue + } + + const composedPath = event.composedPath() + const isMenuTarget = composedPath.includes(context._menu) + if ( + composedPath.includes(context._element) || + (context._config.autoClose === 'inside' && !isMenuTarget) || + (context._config.autoClose === 'outside' && isMenuTarget) + ) { + continue + } + + // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu + if (context._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName))) { + continue + } + + const relatedTarget = { relatedTarget: context._element } + + if (event.type === 'click') { + relatedTarget.clickEvent = event + } + + context._completeHide(relatedTarget) + } + } + + static dataApiKeydownHandler(event) { + // If not an UP | DOWN | ESCAPE key => not a dropdown command + // If input/textarea && if key is other than ESCAPE => not a dropdown command + + const isInput = /input|textarea/i.test(event.target.tagName) + const isEscapeEvent = event.key === ESCAPE_KEY + const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key) + + if (!isUpOrDownEvent && !isEscapeEvent) { + return + } + + if (isInput && !isEscapeEvent) { + return + } + + event.preventDefault() + + // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/ + const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ? + this : + (SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] || + SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] || + SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode)) + + const instance = Dropdown.getOrCreateInstance(getToggleButton) + + if (isUpOrDownEvent) { + event.stopPropagation() + instance.show() + instance._selectMenuItem(event) + return + } + + if (instance._isShown()) { // else is escape and we check if it is shown + event.stopPropagation() + instance.hide() + getToggleButton.focus() + } + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Dropdown.dataApiKeydownHandler) +EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler) +EventHandler.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus) +EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus) +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + event.preventDefault() + Dropdown.getOrCreateInstance(this).toggle() +}) + +/** + * jQuery + */ + +defineJQueryPlugin(Dropdown) + +export default Dropdown diff --git a/app/nossas/design/static/bootstrap/js/src/modal.js b/app/nossas/design/static/bootstrap/js/src/modal.js new file mode 100644 index 00000000..b44cbb94 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/modal.js @@ -0,0 +1,376 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap modal.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' +import Backdrop from './util/backdrop.js' +import { enableDismissTrigger } from './util/component-functions.js' +import FocusTrap from './util/focustrap.js' +import { defineJQueryPlugin, isRTL, isVisible, reflow } from './util/index.js' +import ScrollBarHelper from './util/scrollbar.js' + +/** + * Constants + */ + +const NAME = 'modal' +const DATA_KEY = 'bs.modal' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const ESCAPE_KEY = 'Escape' + +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_RESIZE = `resize${EVENT_KEY}` +const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}` +const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}` +const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_OPEN = 'modal-open' +const CLASS_NAME_FADE = 'fade' +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_STATIC = 'modal-static' + +const OPEN_SELECTOR = '.modal.show' +const SELECTOR_DIALOG = '.modal-dialog' +const SELECTOR_MODAL_BODY = '.modal-body' +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"]' + +const Default = { + backdrop: true, + focus: true, + keyboard: true +} + +const DefaultType = { + backdrop: '(boolean|string)', + focus: 'boolean', + keyboard: 'boolean' +} + +/** + * Class definition + */ + +class Modal extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element) + this._backdrop = this._initializeBackDrop() + this._focustrap = this._initializeFocusTrap() + this._isShown = false + this._isTransitioning = false + this._scrollBar = new ScrollBarHelper() + + this._addEventListeners() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle(relatedTarget) { + return this._isShown ? this.hide() : this.show(relatedTarget) + } + + show(relatedTarget) { + if (this._isShown || this._isTransitioning) { + return + } + + const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { + relatedTarget + }) + + if (showEvent.defaultPrevented) { + return + } + + this._isShown = true + this._isTransitioning = true + + this._scrollBar.hide() + + document.body.classList.add(CLASS_NAME_OPEN) + + this._adjustDialog() + + this._backdrop.show(() => this._showElement(relatedTarget)) + } + + hide() { + if (!this._isShown || this._isTransitioning) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + this._isShown = false + this._isTransitioning = true + this._focustrap.deactivate() + + this._element.classList.remove(CLASS_NAME_SHOW) + + this._queueCallback(() => this._hideModal(), this._element, this._isAnimated()) + } + + dispose() { + EventHandler.off(window, EVENT_KEY) + EventHandler.off(this._dialog, EVENT_KEY) + + this._backdrop.dispose() + this._focustrap.deactivate() + + super.dispose() + } + + handleUpdate() { + this._adjustDialog() + } + + // Private + _initializeBackDrop() { + return new Backdrop({ + isVisible: Boolean(this._config.backdrop), // 'static' option will be translated to true, and booleans will keep their value, + isAnimated: this._isAnimated() + }) + } + + _initializeFocusTrap() { + return new FocusTrap({ + trapElement: this._element + }) + } + + _showElement(relatedTarget) { + // try to append dynamic modal + if (!document.body.contains(this._element)) { + document.body.append(this._element) + } + + this._element.style.display = 'block' + this._element.removeAttribute('aria-hidden') + this._element.setAttribute('aria-modal', true) + this._element.setAttribute('role', 'dialog') + this._element.scrollTop = 0 + + const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog) + if (modalBody) { + modalBody.scrollTop = 0 + } + + reflow(this._element) + + this._element.classList.add(CLASS_NAME_SHOW) + + const transitionComplete = () => { + if (this._config.focus) { + this._focustrap.activate() + } + + this._isTransitioning = false + EventHandler.trigger(this._element, EVENT_SHOWN, { + relatedTarget + }) + } + + this._queueCallback(transitionComplete, this._dialog, this._isAnimated()) + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { + if (event.key !== ESCAPE_KEY) { + return + } + + if (this._config.keyboard) { + this.hide() + return + } + + this._triggerBackdropTransition() + }) + + EventHandler.on(window, EVENT_RESIZE, () => { + if (this._isShown && !this._isTransitioning) { + this._adjustDialog() + } + }) + + EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, event => { + // a bad trick to segregate clicks that may start inside dialog but end outside, and avoid listen to scrollbar clicks + EventHandler.one(this._element, EVENT_CLICK_DISMISS, event2 => { + if (this._element !== event.target || this._element !== event2.target) { + return + } + + if (this._config.backdrop === 'static') { + this._triggerBackdropTransition() + return + } + + if (this._config.backdrop) { + this.hide() + } + }) + }) + } + + _hideModal() { + this._element.style.display = 'none' + this._element.setAttribute('aria-hidden', true) + this._element.removeAttribute('aria-modal') + this._element.removeAttribute('role') + this._isTransitioning = false + + this._backdrop.hide(() => { + document.body.classList.remove(CLASS_NAME_OPEN) + this._resetAdjustments() + this._scrollBar.reset() + EventHandler.trigger(this._element, EVENT_HIDDEN) + }) + } + + _isAnimated() { + return this._element.classList.contains(CLASS_NAME_FADE) + } + + _triggerBackdropTransition() { + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) + if (hideEvent.defaultPrevented) { + return + } + + const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight + const initialOverflowY = this._element.style.overflowY + // return if the following background transition hasn't yet completed + if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) { + return + } + + if (!isModalOverflowing) { + this._element.style.overflowY = 'hidden' + } + + this._element.classList.add(CLASS_NAME_STATIC) + this._queueCallback(() => { + this._element.classList.remove(CLASS_NAME_STATIC) + this._queueCallback(() => { + this._element.style.overflowY = initialOverflowY + }, this._dialog) + }, this._dialog) + + this._element.focus() + } + + /** + * The following methods are used to handle overflowing modals + */ + + _adjustDialog() { + const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight + const scrollbarWidth = this._scrollBar.getWidth() + const isBodyOverflowing = scrollbarWidth > 0 + + if (isBodyOverflowing && !isModalOverflowing) { + const property = isRTL() ? 'paddingLeft' : 'paddingRight' + this._element.style[property] = `${scrollbarWidth}px` + } + + if (!isBodyOverflowing && isModalOverflowing) { + const property = isRTL() ? 'paddingRight' : 'paddingLeft' + this._element.style[property] = `${scrollbarWidth}px` + } + } + + _resetAdjustments() { + this._element.style.paddingLeft = '' + this._element.style.paddingRight = '' + } + + // Static + static jQueryInterface(config, relatedTarget) { + return this.each(function () { + const data = Modal.getOrCreateInstance(this, config) + + if (typeof config !== 'string') { + return + } + + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](relatedTarget) + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + const target = SelectorEngine.getElementFromSelector(this) + + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + EventHandler.one(target, EVENT_SHOW, showEvent => { + if (showEvent.defaultPrevented) { + // only register focus restorer if modal will actually get shown + return + } + + EventHandler.one(target, EVENT_HIDDEN, () => { + if (isVisible(this)) { + this.focus() + } + }) + }) + + // avoid conflict when clicking modal toggler while another one is open + const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR) + if (alreadyOpen) { + Modal.getInstance(alreadyOpen).hide() + } + + const data = Modal.getOrCreateInstance(target) + + data.toggle(this) +}) + +enableDismissTrigger(Modal) + +/** + * jQuery + */ + +defineJQueryPlugin(Modal) + +export default Modal diff --git a/app/nossas/design/static/bootstrap/js/src/offcanvas.js b/app/nossas/design/static/bootstrap/js/src/offcanvas.js new file mode 100644 index 00000000..8d1feb13 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/offcanvas.js @@ -0,0 +1,282 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap offcanvas.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' +import Backdrop from './util/backdrop.js' +import { enableDismissTrigger } from './util/component-functions.js' +import FocusTrap from './util/focustrap.js' +import { + defineJQueryPlugin, + isDisabled, + isVisible +} from './util/index.js' +import ScrollBarHelper from './util/scrollbar.js' + +/** + * Constants + */ + +const NAME = 'offcanvas' +const DATA_KEY = 'bs.offcanvas' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' +const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}` +const ESCAPE_KEY = 'Escape' + +const CLASS_NAME_SHOW = 'show' +const CLASS_NAME_SHOWING = 'showing' +const CLASS_NAME_HIDING = 'hiding' +const CLASS_NAME_BACKDROP = 'offcanvas-backdrop' +const OPEN_SELECTOR = '.offcanvas.show' + +const EVENT_SHOW = `show${EVENT_KEY}` +const EVENT_SHOWN = `shown${EVENT_KEY}` +const EVENT_HIDE = `hide${EVENT_KEY}` +const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}` +const EVENT_HIDDEN = `hidden${EVENT_KEY}` +const EVENT_RESIZE = `resize${EVENT_KEY}` +const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` +const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"]' + +const Default = { + backdrop: true, + keyboard: true, + scroll: false +} + +const DefaultType = { + backdrop: '(boolean|string)', + keyboard: 'boolean', + scroll: 'boolean' +} + +/** + * Class definition + */ + +class Offcanvas extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._isShown = false + this._backdrop = this._initializeBackDrop() + this._focustrap = this._initializeFocusTrap() + this._addEventListeners() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + toggle(relatedTarget) { + return this._isShown ? this.hide() : this.show(relatedTarget) + } + + show(relatedTarget) { + if (this._isShown) { + return + } + + const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget }) + + if (showEvent.defaultPrevented) { + return + } + + this._isShown = true + this._backdrop.show() + + if (!this._config.scroll) { + new ScrollBarHelper().hide() + } + + this._element.setAttribute('aria-modal', true) + this._element.setAttribute('role', 'dialog') + this._element.classList.add(CLASS_NAME_SHOWING) + + const completeCallBack = () => { + if (!this._config.scroll || this._config.backdrop) { + this._focustrap.activate() + } + + this._element.classList.add(CLASS_NAME_SHOW) + this._element.classList.remove(CLASS_NAME_SHOWING) + EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget }) + } + + this._queueCallback(completeCallBack, this._element, true) + } + + hide() { + if (!this._isShown) { + return + } + + const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) + + if (hideEvent.defaultPrevented) { + return + } + + this._focustrap.deactivate() + this._element.blur() + this._isShown = false + this._element.classList.add(CLASS_NAME_HIDING) + this._backdrop.hide() + + const completeCallback = () => { + this._element.classList.remove(CLASS_NAME_SHOW, CLASS_NAME_HIDING) + this._element.removeAttribute('aria-modal') + this._element.removeAttribute('role') + + if (!this._config.scroll) { + new ScrollBarHelper().reset() + } + + EventHandler.trigger(this._element, EVENT_HIDDEN) + } + + this._queueCallback(completeCallback, this._element, true) + } + + dispose() { + this._backdrop.dispose() + this._focustrap.deactivate() + super.dispose() + } + + // Private + _initializeBackDrop() { + const clickCallback = () => { + if (this._config.backdrop === 'static') { + EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) + return + } + + this.hide() + } + + // 'static' option will be translated to true, and booleans will keep their value + const isVisible = Boolean(this._config.backdrop) + + return new Backdrop({ + className: CLASS_NAME_BACKDROP, + isVisible, + isAnimated: true, + rootElement: this._element.parentNode, + clickCallback: isVisible ? clickCallback : null + }) + } + + _initializeFocusTrap() { + return new FocusTrap({ + trapElement: this._element + }) + } + + _addEventListeners() { + EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, event => { + if (event.key !== ESCAPE_KEY) { + return + } + + if (this._config.keyboard) { + this.hide() + return + } + + EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) + }) + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = Offcanvas.getOrCreateInstance(this, config) + + if (typeof config !== 'string') { + return + } + + if (data[config] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) + } + + data[config](this) + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { + const target = SelectorEngine.getElementFromSelector(this) + + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + if (isDisabled(this)) { + return + } + + EventHandler.one(target, EVENT_HIDDEN, () => { + // focus on trigger when it is closed + if (isVisible(this)) { + this.focus() + } + }) + + // avoid conflict when clicking a toggler of an offcanvas, while another is open + const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR) + if (alreadyOpen && alreadyOpen !== target) { + Offcanvas.getInstance(alreadyOpen).hide() + } + + const data = Offcanvas.getOrCreateInstance(target) + data.toggle(this) +}) + +EventHandler.on(window, EVENT_LOAD_DATA_API, () => { + for (const selector of SelectorEngine.find(OPEN_SELECTOR)) { + Offcanvas.getOrCreateInstance(selector).show() + } +}) + +EventHandler.on(window, EVENT_RESIZE, () => { + for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) { + if (getComputedStyle(element).position !== 'fixed') { + Offcanvas.getOrCreateInstance(element).hide() + } + } +}) + +enableDismissTrigger(Offcanvas) + +/** + * jQuery + */ + +defineJQueryPlugin(Offcanvas) + +export default Offcanvas diff --git a/app/nossas/design/static/bootstrap/js/src/popover.js b/app/nossas/design/static/bootstrap/js/src/popover.js new file mode 100644 index 00000000..612c5218 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/popover.js @@ -0,0 +1,97 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap popover.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import Tooltip from './tooltip.js' +import { defineJQueryPlugin } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'popover' + +const SELECTOR_TITLE = '.popover-header' +const SELECTOR_CONTENT = '.popover-body' + +const Default = { + ...Tooltip.Default, + content: '', + offset: [0, 8], + placement: 'right', + template: '', + trigger: 'click' +} + +const DefaultType = { + ...Tooltip.DefaultType, + content: '(null|string|element|function)' +} + +/** + * Class definition + */ + +class Popover extends Tooltip { + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Overrides + _isWithContent() { + return this._getTitle() || this._getContent() + } + + // Private + _getContentForTemplate() { + return { + [SELECTOR_TITLE]: this._getTitle(), + [SELECTOR_CONTENT]: this._getContent() + } + } + + _getContent() { + return this._resolvePossibleFunction(this._config.content) + } + + // Static + static jQueryInterface(config) { + return this.each(function () { + const data = Popover.getOrCreateInstance(this, config) + + if (typeof config !== 'string') { + return + } + + if (typeof data[config] === 'undefined') { + throw new TypeError(`No method named "${config}"`) + } + + data[config]() + }) + } +} + +/** + * jQuery + */ + +defineJQueryPlugin(Popover) + +export default Popover diff --git a/app/nossas/design/static/bootstrap/js/src/scrollspy.js b/app/nossas/design/static/bootstrap/js/src/scrollspy.js new file mode 100644 index 00000000..69de7151 --- /dev/null +++ b/app/nossas/design/static/bootstrap/js/src/scrollspy.js @@ -0,0 +1,294 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap scrollspy.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' +import { defineJQueryPlugin, getElement, isDisabled, isVisible } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'scrollspy' +const DATA_KEY = 'bs.scrollspy' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_ACTIVATE = `activate${EVENT_KEY}` +const EVENT_CLICK = `click${EVENT_KEY}` +const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}` + +const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item' +const CLASS_NAME_ACTIVE = 'active' + +const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"]' +const SELECTOR_TARGET_LINKS = '[href]' +const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group' +const SELECTOR_NAV_LINKS = '.nav-link' +const SELECTOR_NAV_ITEMS = '.nav-item' +const SELECTOR_LIST_ITEMS = '.list-group-item' +const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}` +const SELECTOR_DROPDOWN = '.dropdown' +const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle' + +const Default = { + offset: null, // TODO: v6 @deprecated, keep it for backwards compatibility reasons + rootMargin: '0px 0px -25%', + smoothScroll: false, + target: null, + threshold: [0.1, 0.5, 1] +} + +const DefaultType = { + offset: '(number|null)', // TODO v6 @deprecated, keep it for backwards compatibility reasons + rootMargin: 'string', + smoothScroll: 'boolean', + target: 'element', + threshold: 'array' +} + +/** + * Class definition + */ + +class ScrollSpy extends BaseComponent { + constructor(element, config) { + super(element, config) + + // this._element is the observablesContainer and config.target the menu links wrapper + this._targetLinks = new Map() + this._observableSections = new Map() + this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element + this._activeTarget = null + this._observer = null + this._previousScrollData = { + visibleEntryTop: 0, + parentScrollTop: 0 + } + this.refresh() // initialize + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + refresh() { + this._initializeTargetsAndObservables() + this._maybeEnableSmoothScroll() + + if (this._observer) { + this._observer.disconnect() + } else { + this._observer = this._getNewObserver() + } + + for (const section of this._observableSections.values()) { + this._observer.observe(section) + } + } + + dispose() { + this._observer.disconnect() + super.dispose() + } + + // Private + _configAfterMerge(config) { + // TODO: on v6 target should be given explicitly & remove the {target: 'ss-target'} case + config.target = getElement(config.target) || document.body + + // TODO: v6 Only for backwards compatibility reasons. Use rootMargin only + config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin + + if (typeof config.threshold === 'string') { + config.threshold = config.threshold.split(',').map(value => Number.parseFloat(value)) + } + + return config + } + + _maybeEnableSmoothScroll() { + if (!this._config.smoothScroll) { + return + } + + // unregister any previous listeners + EventHandler.off(this._config.target, EVENT_CLICK) + + EventHandler.on(this._config.target, EVENT_CLICK, SELECTOR_TARGET_LINKS, event => { + const observableSection = this._observableSections.get(event.target.hash) + if (observableSection) { + event.preventDefault() + const root = this._rootElement || window + const height = observableSection.offsetTop - this._element.offsetTop + if (root.scrollTo) { + root.scrollTo({ top: height, behavior: 'smooth' }) + return + } + + // Chrome 60 doesn't support `scrollTo` + root.scrollTop = height + } + }) + } + + _getNewObserver() { + const options = { + root: this._rootElement, + threshold: this._config.threshold, + rootMargin: this._config.rootMargin + } + + return new IntersectionObserver(entries => this._observerCallback(entries), options) + } + + // The logic of selection + _observerCallback(entries) { + const targetElement = entry => this._targetLinks.get(`#${entry.target.id}`) + const activate = entry => { + this._previousScrollData.visibleEntryTop = entry.target.offsetTop + this._process(targetElement(entry)) + } + + const parentScrollTop = (this._rootElement || document.documentElement).scrollTop + const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop + this._previousScrollData.parentScrollTop = parentScrollTop + + for (const entry of entries) { + if (!entry.isIntersecting) { + this._activeTarget = null + this._clearActiveClass(targetElement(entry)) + + continue + } + + const entryIsLowerThanPrevious = entry.target.offsetTop >= this._previousScrollData.visibleEntryTop + // if we are scrolling down, pick the bigger offsetTop + if (userScrollsDown && entryIsLowerThanPrevious) { + activate(entry) + // if parent isn't scrolled, let's keep the first visible item, breaking the iteration + if (!parentScrollTop) { + return + } + + continue + } + + // if we are scrolling up, pick the smallest offsetTop + if (!userScrollsDown && !entryIsLowerThanPrevious) { + activate(entry) + } + } + } + + _initializeTargetsAndObservables() { + this._targetLinks = new Map() + this._observableSections = new Map() + + const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target) + + for (const anchor of targetLinks) { + // ensure that the anchor has an id and is not disabled + if (!anchor.hash || isDisabled(anchor)) { + continue + } + + const observableSection = SelectorEngine.findOne(decodeURI(anchor.hash), this._element) + + // ensure that the observableSection exists & is visible + if (isVisible(observableSection)) { + this._targetLinks.set(decodeURI(anchor.hash), anchor) + this._observableSections.set(anchor.hash, observableSection) + } + } + } + + _process(target) { + if (this._activeTarget === target) { + return + } + + this._clearActiveClass(this._config.target) + this._activeTarget = target + target.classList.add(CLASS_NAME_ACTIVE) + this._activateParents(target) + + EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target }) + } + + _activateParents(target) { + // Activate dropdown parents + if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) { + SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN)) + .classList.add(CLASS_NAME_ACTIVE) + return + } + + for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) { + // Set triggered links parents as active + // With both