From d9666bda5b175816033152d383fdff3e13724d63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:03:36 +0200 Subject: [PATCH 1/3] ci:(deps): bump actions/cache from 4.0.2 to 4.1.0 (#237) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8fb42726..d40d7b94 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,7 @@ jobs: python-version: '3.11' cache: 'pip' - name: Cache dependencies - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.0 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.in') }} @@ -44,7 +44,7 @@ jobs: python-version: '3.11' cache: 'pip' - name: Cache dependencies - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.1.0 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.in') }} From d540b1e2b716a09a752c594626120b5ea006938a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 22:53:30 +0200 Subject: [PATCH 2/3] ci: pre-commit autoupdate (#238) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 24b0e287..b0902ddf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,12 +9,12 @@ ci: repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.16.0 + rev: v3.17.0 hooks: - id: pyupgrade args: ["--py37-plus"] - repo: https://github.com/adamchainz/django-upgrade - rev: "1.19.0" + rev: "1.21.0" hooks: - id: django-upgrade args: [--target-version, "2.2"] @@ -24,7 +24,7 @@ repos: - id: isort name: isort (python) - repo: https://github.com/PyCQA/flake8 - rev: 7.1.0 + rev: 7.1.1 hooks: - id: flake8 additional_dependencies: From a50d6792bf07406fb60ae83e9cf3d981129c28d3 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 7 Oct 2024 23:10:48 +0200 Subject: [PATCH 3/3] feat: Add re-usable components (#220) --- .editorconfig | 3 + .github/workflows/codecov.yml | 13 +- .github/workflows/docs.yml | 4 +- README.rst | 36 +-- djangocms_frontend/__init__.py | 2 +- djangocms_frontend/apps.py | 11 + djangocms_frontend/cms_plugins.py | 36 ++- djangocms_frontend/common/__init__.py | 40 ++++ djangocms_frontend/common/background.py | 15 -- .../common/{ => bootstrap5}/attributes.py | 0 .../common/{ => bootstrap5}/spacing.py | 1 + djangocms_frontend/common/responsive.py | 15 -- djangocms_frontend/common/sizing.py | 15 -- djangocms_frontend/component_pool.py | 97 ++++++++ .../contrib/accordion/cms_plugins.py | 4 +- .../bootstrap5/accordion.html | 5 +- .../bootstrap5/accordion_item.html | 11 +- .../contrib/alert/cms_plugins.py | 4 +- djangocms_frontend/contrib/alert/forms.py | 3 +- .../contrib/badge/cms_plugins.py | 2 +- .../contrib/card/cms_plugins.py | 11 +- djangocms_frontend/contrib/card/forms.py | 13 +- .../contrib/carousel/cms_plugins.py | 5 +- djangocms_frontend/contrib/carousel/forms.py | 2 +- .../bootstrap5/carousel/default/image.html | 4 +- .../contrib/collapse/cms_plugins.py | 2 +- .../contrib/component/__init__.py | 0 .../contrib/component/cms_plugins.py | 18 ++ .../contrib/component/components.py | 213 ++++++++++++++++++ .../contrib/component/registry.py | 25 ++ .../templates/djangocms_frontend/slot.html | 1 + .../contrib/content/cms_plugins.py | 5 +- djangocms_frontend/contrib/content/forms.py | 4 +- .../contrib/grid/cms_plugins.py | 17 +- djangocms_frontend/contrib/grid/forms.py | 18 +- .../contrib/grid/frameworks/bootstrap5.py | 6 +- djangocms_frontend/contrib/grid/models.py | 16 +- .../contrib/icon/cms_plugins.py | 5 +- djangocms_frontend/contrib/icon/forms.py | 4 +- .../contrib/icon/templatetags/icon_tags.py | 10 + .../contrib/image/cms_plugins.py | 11 +- djangocms_frontend/contrib/image/fields.py | 11 + djangocms_frontend/contrib/image/forms.py | 4 +- .../contrib/image/frameworks/bootstrap5.py | 7 - .../contrib/jumbotron/cms_plugins.py | 5 +- djangocms_frontend/contrib/jumbotron/forms.py | 10 +- djangocms_frontend/contrib/link/__init__.py | 11 - .../contrib/link/cms_plugins.py | 8 +- djangocms_frontend/contrib/link/forms.py | 3 +- .../contrib/link/frameworks/bootstrap5.py | 2 +- .../contrib/link/frameworks/tailwind.py | 0 djangocms_frontend/contrib/link/helpers.py | 116 +++++++++- djangocms_frontend/contrib/link/models.py | 94 +------- .../tailwind/link/default/icon.html | 2 + .../tailwind/link/default/link.html | 1 + .../contrib/listgroup/cms_plugins.py | 4 +- djangocms_frontend/contrib/listgroup/forms.py | 3 +- .../contrib/media/cms_plugins.py | 3 +- djangocms_frontend/contrib/media/forms.py | 2 +- .../contrib/navigation/cms_plugins.py | 3 +- .../contrib/navigation/forms.py | 2 +- .../contrib/tabs/cms_plugins.py | 5 +- djangocms_frontend/contrib/tabs/forms.py | 2 +- .../contrib/utilities/cms_plugins.py | 6 +- djangocms_frontend/contrib/utilities/forms.py | 4 +- .../templates/djangocms_frontend/heading.html | 4 +- djangocms_frontend/frameworks/tailwind.py | 95 ++++++++ djangocms_frontend/helpers.py | 119 ++++++++++ djangocms_frontend/models.py | 32 +-- djangocms_frontend/settings.py | 4 +- .../static/djangocms_frontend/css/base.css | 2 +- .../css/structure_board.css | 0 .../djangocms_frontend/html_container.html | 6 +- .../djangocms_frontend/user_message.html | 5 + .../templates/tailwind/base.html | 57 +++++ .../templates/tailwind/dropdown.html | 5 + .../templates/tailwind/menu.html | 26 +++ djangocms_frontend/templatetags/frontend.py | 193 ++++++++++++++++ djangocms_frontend/views.py | 0 docs/requirements.txt | 89 ++++---- docs/source/components.rst | 192 +++++++++++++++- docs/source/conf.py | 2 +- docs/source/custom_components.rst | 121 ++++++++++ docs/source/getting_started.rst | 49 +++- docs/source/grid.rst | 53 +++++ .../how-to/use-frontend-as-component.rst | 1 + docs/source/index.rst | 1 + docs/source/reference.rst | 61 +++-- private/sass/components/_button-group.scss | 1 + run_tests.py | 9 +- setup.py | 5 +- tests/accordion/test_plugins.py | 2 +- tests/component/__init__.py | 0 tests/component/test_models.py | 14 ++ tests/component/test_plugins.py | 168 ++++++++++++++ tests/fixtures.py | 77 ++++--- tests/grid/test_plugins.py | 2 +- tests/image/test_drag_n_drop.py | 2 +- tests/image/test_plugins.py | 4 +- tests/link/test_plugins.py | 10 +- tests/requirements/base.txt | 3 + tests/requirements/dj32_cms310.txt | 5 - tests/requirements/dj32_cms38.txt | 6 - tests/requirements/dj32_cms39.txt | 5 - tests/requirements/dj32_cms41.txt | 8 - tests/requirements/dj40_cms311.txt | 6 - tests/requirements/dj40_cms41.txt | 8 - tests/requirements/dj41_cms311.txt | 6 - tests/requirements/dj41_cms41.txt | 8 - tests/requirements/dj42_cms311.txt | 1 - tests/requirements/dj42_cms41.txt | 4 +- tests/requirements/dj50_cms41.txt | 4 +- tests/requirements/dj51_cms41.txt | 5 + tests/test_app/__init__.py | 0 tests/test_app/cms_components.py | 50 ++++ tests/test_app/migrations/__init__.py | 0 tests/test_app/templates/button.html | 1 + tests/test_app/templates/hero.html | 27 +++ tests/test_migrations.py | 2 +- tests/test_plugin_tag.py | 123 ++++++++++ tests/test_settings.py | 18 +- tests/utilities/test_plugins.py | 25 ++ 122 files changed, 2240 insertions(+), 501 deletions(-) create mode 100644 djangocms_frontend/apps.py delete mode 100644 djangocms_frontend/common/background.py rename djangocms_frontend/common/{ => bootstrap5}/attributes.py (100%) rename djangocms_frontend/common/{ => bootstrap5}/spacing.py (99%) delete mode 100644 djangocms_frontend/common/responsive.py delete mode 100644 djangocms_frontend/common/sizing.py create mode 100644 djangocms_frontend/component_pool.py create mode 100644 djangocms_frontend/contrib/component/__init__.py create mode 100644 djangocms_frontend/contrib/component/cms_plugins.py create mode 100644 djangocms_frontend/contrib/component/components.py create mode 100644 djangocms_frontend/contrib/component/registry.py create mode 100644 djangocms_frontend/contrib/component/templates/djangocms_frontend/slot.html create mode 100644 djangocms_frontend/contrib/image/fields.py create mode 100644 djangocms_frontend/contrib/link/frameworks/tailwind.py create mode 100644 djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/icon.html create mode 100644 djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/link.html create mode 100644 djangocms_frontend/frameworks/tailwind.py create mode 100644 djangocms_frontend/static/djangocms_frontend/css/structure_board.css create mode 100644 djangocms_frontend/templates/djangocms_frontend/user_message.html create mode 100644 djangocms_frontend/templates/tailwind/base.html create mode 100644 djangocms_frontend/templates/tailwind/dropdown.html create mode 100644 djangocms_frontend/templates/tailwind/menu.html create mode 100644 djangocms_frontend/views.py create mode 100644 docs/source/custom_components.rst create mode 100644 docs/source/how-to/use-frontend-as-component.rst create mode 100644 tests/component/__init__.py create mode 100644 tests/component/test_models.py create mode 100644 tests/component/test_plugins.py delete mode 100644 tests/requirements/dj32_cms310.txt delete mode 100644 tests/requirements/dj32_cms38.txt delete mode 100644 tests/requirements/dj32_cms39.txt delete mode 100644 tests/requirements/dj32_cms41.txt delete mode 100644 tests/requirements/dj40_cms311.txt delete mode 100644 tests/requirements/dj40_cms41.txt delete mode 100644 tests/requirements/dj41_cms311.txt delete mode 100644 tests/requirements/dj41_cms41.txt create mode 100644 tests/requirements/dj51_cms41.txt create mode 100644 tests/test_app/__init__.py create mode 100644 tests/test_app/cms_components.py create mode 100644 tests/test_app/migrations/__init__.py create mode 100644 tests/test_app/templates/button.html create mode 100644 tests/test_app/templates/hero.html create mode 100644 tests/test_plugin_tag.py diff --git a/.editorconfig b/.editorconfig index 4545b663..e6a397d4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -38,3 +38,6 @@ insert_final_newline = false [*plugins/image.html] insert_final_newline = false + +[*.html] +indent_size = 2 diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index d19646e0..e786787e 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -12,17 +12,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.8, 3.9, "3.10", "3.11"] # latest release minus two + python-version: ["3.10", "3.11", "3.12"] # latest release minus two requirements-file: [ - dj32_cms38.txt, - dj32_cms39.txt, - dj32_cms41.txt, - dj40_cms311.txt, - dj41_cms311.txt, dj42_cms311.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] os: [ ubuntu-20.04, @@ -40,6 +35,6 @@ jobs: - name: Generate Report run: | pip install -r tests/requirements/${{ matrix.requirements-file }} - coverage run setup.py test + coverage run run_tests.py - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d40d7b94..e731b3fa 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,7 +25,7 @@ jobs: key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.in') }} restore-keys: | ${{ runner.os }}-pip- - - run: python -m pip install -r docs/requirements.in + - run: python -m pip install -r docs/requirements.txt - name: Build docs run: | cd docs @@ -50,7 +50,7 @@ jobs: key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.in') }} restore-keys: | ${{ runner.os }}-pip- - - run: python -m pip install -r docs/requirements.in + - run: python -m pip install -r docs/requirements.txt - name: Check spelling run: | cd docs diff --git a/README.rst b/README.rst index adbe2733..1fc5ac3e 100644 --- a/README.rst +++ b/README.rst @@ -14,27 +14,31 @@ currently used frontend framework such as Bootstrap, or its specific version. Key features ============ -- Support of `Bootstrap 5 `_, django CMS 3.8+ - and the new upcoming major django CMS 4. +- Support of `Bootstrap 5 `_, django CMS 3.8+ + and django CMS 4. -- **Separation of plugins from css framework**, i.e. no need to - rebuild you site's plugin tree if css framework is changed in the - future, e.g. from Bootstrap 5 to a future version. +- **Separation of plugins from css framework**, i.e. no need to + rebuild you site's plugin tree if css framework is changed in the + future, e.g. from Bootstrap 5 to a future version. -- **New link plugin** allowing to link to internal pages provided by - other applications, such as `djangocms-blog - `_. +- **New link plugin** allowing to link to internal pages provided by + other applications, such as `djangocms-blog + `_. -- **Nice and well-arranged admin frontend** of `djangocms-bootstrap4 - `_ +- **Nice and well-arranged admin frontend** of `djangocms-bootstrap4 + `_ + +- **Extensible** within the project and with separate project (e.g. a + theme app). Create your own components with a few lines of code only. + +- **Plugins are re-usable as UI components** anywhere in your project + (e.g. in a custom app) giving your whole project a more consistent + user experience. - A management command to **migrate from djangocms-bootstrap4**. This command automatically migrates all ``djangocms-bootstrap4`` plugins to ``djangocms-frontend``. -- **Extensible** within the project and with separate project (e.g. a - theme app) - Description =========== @@ -55,6 +59,10 @@ The link plugin has been rewritten to not only allow internal links to other CMS pages, but also to other django models such as, e.g., posts of `djangocms-blog `_. +The plugins are designed to be re-usable as UI components in your +project, e.g. in a custom app, giving your whole project a more +consistent user experience. + Contributing ============ @@ -150,7 +158,7 @@ See readthedocs for the `documentation License ======= -See `LICENSE `_. +See `LICENSE `_. .. |pypi| image:: https://badge.fury.io/py/djangocms-frontend.svg :target: http://badge.fury.io/py/djangocms-frontend diff --git a/djangocms_frontend/__init__.py b/djangocms_frontend/__init__.py index d8e1e865..f4bc00fb 100644 --- a/djangocms_frontend/__init__.py +++ b/djangocms_frontend/__init__.py @@ -19,4 +19,4 @@ 13. Github actions will publish the new package to pypi """ -__version__ = "1.3.4" +__version__ = "2.0.0a" diff --git a/djangocms_frontend/apps.py b/djangocms_frontend/apps.py new file mode 100644 index 00000000..444c1cee --- /dev/null +++ b/djangocms_frontend/apps.py @@ -0,0 +1,11 @@ +from django import apps + + +class DjangocmsFrontendConfig(apps.AppConfig): + name = "djangocms_frontend" + verbose_name = "DjangoCMS Frontend" + + def ready(self): + from .component_pool import setup + + setup() diff --git a/djangocms_frontend/cms_plugins.py b/djangocms_frontend/cms_plugins.py index cd9049a9..d4610163 100644 --- a/djangocms_frontend/cms_plugins.py +++ b/djangocms_frontend/cms_plugins.py @@ -1,10 +1,44 @@ +from cms.constants import SLUG_REGEXP from cms.plugin_base import CMSPluginBase from django.utils.encoding import force_str +from djangocms_frontend.helpers import get_related -class CMSUIPlugin(CMSPluginBase): +if hasattr(CMSPluginBase, "edit_field"): + # FrontendEditable functionality already implemented in core? + FrontendEditableAdminMixin = object +else: + # If not use our own version of the plugin-enabled mixin + from .helpers import FrontendEditableAdminMixin + + +class CMSUIPlugin(FrontendEditableAdminMixin, CMSPluginBase): render_template = "djangocms_frontend/html_container.html" change_form_template = "djangocms_frontend/admin/base.html" def __str__(self): return force_str(super().__str__()) + + def render(self, context, instance, placeholder): + for key, value in instance.config.items(): + if isinstance(value, dict) and set(value.keys()) == {"pk", "model"}: + if key not in instance.__dir__(): # hasattr would return the value in the config dict + setattr(instance.__class__, key, get_related(key)) + return super().render(context, instance, placeholder) + + def get_plugin_urls(self): + from django.urls import re_path + + info = f"{self.model._meta.app_label}_{self.model._meta.model_name}" + + def pat(regex, fn): + return re_path(regex, fn, name=f"{info}_{fn.__name__}") + + return [ + pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field), + ] + + def _get_object_for_single_field(self, object_id, language): + from .models import FrontendUIItem + + return FrontendUIItem.objects.get(pk=object_id) diff --git a/djangocms_frontend/common/__init__.py b/djangocms_frontend/common/__init__.py index e69de29b..0957b32d 100644 --- a/djangocms_frontend/common/__init__.py +++ b/djangocms_frontend/common/__init__.py @@ -0,0 +1,40 @@ +from importlib import import_module + +from djangocms_frontend import settings + +from .title import TitleFormMixin, TitleMixin + +__common = { + "attributes": ("AttributesMixin",), + "background": ("BackgroundFormMixin", "BackgroundMixin"), + "responsive": ("ResponsiveFormMixin", "ResponsiveMixin"), + "sizing": ("SizingFormMixin", "SizingMixin"), + "spacing": ("SpacingFormMixin", "SpacingMixin", "MarginFormMixin", "MarginMixin", "PaddingFormMixin", "PaddingMixin"), +} + +for module, classes in __common.items(): + try: + module = import_module(f"{__name__}.{settings.framework}.{module}", module) + for cls in classes: + globals()[cls] = getattr(module, cls) + except ModuleNotFoundError: + for cls in classes: + globals()[cls] = type(cls, (object,), {}) + +__all__ = [ + "TitleMixin", + "TitleFormMixin", + "AttributesMixin", + "BackgroundFormMixin", + "BackgroundMixin", + "ResponsiveFormMixin", + "ResponsiveMixin", + "SizingFormMixin", + "SizingMixin", + "SpacingFormMixin", + "SpacingMixin", + "MarginFormMixin", + "MarginMixin", + "PaddingFormMixin", + "PaddingMixin", +] diff --git a/djangocms_frontend/common/background.py b/djangocms_frontend/common/background.py deleted file mode 100644 index 75d87669..00000000 --- a/djangocms_frontend/common/background.py +++ /dev/null @@ -1,15 +0,0 @@ -from importlib import import_module - -from djangocms_frontend import settings - -try: - module = import_module(f"..{settings.framework}.background", __name__) - BackgroundFormMixin = module.BackgroundFormMixin - BackgroundMixin = module.BackgroundMixin -except ModuleNotFoundError: - - class BackgroundMixin: - pass - - class BackgroundFormMixin: - pass diff --git a/djangocms_frontend/common/attributes.py b/djangocms_frontend/common/bootstrap5/attributes.py similarity index 100% rename from djangocms_frontend/common/attributes.py rename to djangocms_frontend/common/bootstrap5/attributes.py diff --git a/djangocms_frontend/common/spacing.py b/djangocms_frontend/common/bootstrap5/spacing.py similarity index 99% rename from djangocms_frontend/common/spacing.py rename to djangocms_frontend/common/bootstrap5/spacing.py index c4b98c55..1cd7be1b 100644 --- a/djangocms_frontend/common/spacing.py +++ b/djangocms_frontend/common/bootstrap5/spacing.py @@ -74,6 +74,7 @@ def compress(self, data_list): return "" def clean(self, value): + value = value or ["", ""] if value[1] and not value[0]: raise ValidationError( _("Please choose a side to which the spacing should be applied."), diff --git a/djangocms_frontend/common/responsive.py b/djangocms_frontend/common/responsive.py deleted file mode 100644 index 0c10c5c7..00000000 --- a/djangocms_frontend/common/responsive.py +++ /dev/null @@ -1,15 +0,0 @@ -from importlib import import_module - -from djangocms_frontend import settings - -try: - module = import_module(f"..{settings.framework}.responsive", __name__) - ResponsiveFormMixin = module.ResponsiveFormMixin - ResponsiveMixin = module.ResponsiveMixin -except ModuleNotFoundError: - - class ResponsiveMixin: - pass - - class ResponsiveFormMixin: - pass diff --git a/djangocms_frontend/common/sizing.py b/djangocms_frontend/common/sizing.py deleted file mode 100644 index 2d56b3a5..00000000 --- a/djangocms_frontend/common/sizing.py +++ /dev/null @@ -1,15 +0,0 @@ -from importlib import import_module - -from djangocms_frontend import settings - -try: - module = import_module(f"..{settings.framework}.sizing", __name__) - SizingFormMixin = module.SizingFormMixin - SizingMixin = module.SizingMixin -except ModuleNotFoundError: - - class SizingMixin: - pass - - class SizingFormMixin: - pass diff --git a/djangocms_frontend/component_pool.py b/djangocms_frontend/component_pool.py new file mode 100644 index 00000000..c1024d32 --- /dev/null +++ b/djangocms_frontend/component_pool.py @@ -0,0 +1,97 @@ +import copy +import importlib +import warnings + +from cms.plugin_pool import plugin_pool +from cms.templatetags.cms_tags import render_plugin +from django.conf import settings +from django.contrib.admin.sites import site as admin_site +from django.template import engines +from django.template.library import SimpleNode +from django.template.loader import get_template + +django_engine = engines["django"] + +plugin_tag_pool = {} + + +IGNORED_FIELDS = ( + "id", + "cmsplugin_ptr", + "language", + "plugin_type", + "position", + "creation_date", + "ui_item", +) + +allowed_plugin_types = tuple( + getattr(importlib.import_module(cls.rsplit(".", 1)[0]), cls.rsplit(".", 1)[-1]) if isinstance(cls, str) else cls + for cls in getattr(settings, "CMS_COMPONENT_PLUGINS", []) +) + + +def _get_plugindefaults(instance): + defaults = { + field.name: getattr(instance, field.name) + for field in instance._meta.fields + if field.name not in IGNORED_FIELDS and bool(getattr(instance, field.name)) + } + defaults["plugin_type"] = instance.__class__.__name__ + return defaults + + +class _DummyUser: + is_superuser = True + is_staff = True + + +class _DummyRequest: + user = _DummyUser() + + +def render_dummy_plugin(context, dummy_plugin): + return dummy_plugin.nodelist.render(context) + + +def patch_template(template): + """Patches the template to use the dummy plugin renderer instead of the real one.""" + copied_template = copy.deepcopy(template) + patch = False + for node in copied_template.template.nodelist.get_nodes_by_type(SimpleNode): + if node.func == render_plugin: + patch = True + node.func = render_dummy_plugin + return copied_template if patch else template + + +def setup(): + global plugin_tag_pool + + for plugin in plugin_pool.get_all_plugins(): + if not issubclass(plugin, allowed_plugin_types): + continue + tag_name = plugin.__name__.lower() + if tag_name.endswith("plugin"): + tag_name = tag_name[:-6] + try: + instance = plugin.model() # Create instance with defaults + plugin_admin = plugin(admin_site=admin_site) + if hasattr(instance, "initialize_from_form"): + instance.initialize_from_form(plugin.form) + if tag_name not in plugin_tag_pool: + template = get_template(plugin_admin._get_render_template({"request": None}, instance, None)) + plugin_tag_pool[tag_name] = { + "defaults": { + **_get_plugindefaults(instance), + **dict(plugin_type=plugin.__name__), + }, + "template": patch_template(template), + "class": plugin, + } + else: # pragma: no cover + warnings.warn( + f"Duplicate candidates for {{% plugin \"{tag_name}\" %}} found. " + f"Only registered {plugin_tag_pool[tag_name]['class'].__name__}.", stacklevel=1) + except Exception as exc: # pragma: no cover + warnings.warn(f"{plugin.__name__}: \n{str(exc)}", stacklevel=1) diff --git a/djangocms_frontend/contrib/accordion/cms_plugins.py b/djangocms_frontend/contrib/accordion/cms_plugins.py index 0373c060..1bb50d4c 100644 --- a/djangocms_frontend/contrib/accordion/cms_plugins.py +++ b/djangocms_frontend/contrib/accordion/cms_plugins.py @@ -3,7 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin +from ...common import AttributesMixin from ...helpers import add_plugin from .. import accordion from . import forms, models @@ -98,3 +98,5 @@ class AccordionItemPlugin(mixin_factory("AccordionItem"), CMSUIPlugin): }, ), ] + + frontend_editable_fields = ("accordion_item_header",) diff --git a/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion.html b/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion.html index cdb8ddd8..da1c5802 100644 --- a/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion.html +++ b/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion.html @@ -1,6 +1,7 @@ -{% load cms_tags %} -<{{ instance.tag_type }}{{ instance.get_attributes }} id="parent-{{ instance.pk|safe }}"> +{% load cms_tags frontend %} +<{{ instance.tag_type }}{{ instance.get_attributes }} id="parent-{{ instance.uuid }}"> {% for plugin in instance.child_plugin_instances %} {% with parentloop=forloop parent=instance %}{% render_plugin plugin %}{% endwith %} + {% empty %}{% user_message _("Add accordion items here") %} {% endfor %} diff --git a/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion_item.html b/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion_item.html index a8bbcaf4..f2ce4994 100644 --- a/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion_item.html +++ b/djangocms_frontend/contrib/accordion/templates/djangocms_frontend/bootstrap5/accordion_item.html @@ -1,20 +1,21 @@ -{% load cms_tags %} +{% load cms_tags frontend %} {% spaceless %}
<{{ parent.accordion_header_type|default:"h2" }} class="accordion-header" - id="heading-{{ instance.pk|safe }}"> + aria-controls="item-{{ instance.uuid }}">{% inline_field instance "accordion_item_header" %} - <{{ instance.tag_type }}{{ instance.get_attributes }} id="item-{{ instance.pk|safe }}" aria-labelledby="heading-{{ instance.pk|safe }}" data-bs-parent="#parent-{{ parent.pk|safe }}"> + <{{ instance.tag_type }}{{ instance.get_attributes }} id="item-{{ instance.uuid }}" aria-labelledby="heading-{{ instance.uuid }}" data-bs-parent="#parent-{{ parent.uuid }}">
{% endspaceless %} {% with parent=instance %} {% for plugin in instance.child_plugin_instances %} {% with forloop as parentloop %}{% render_plugin plugin %}{% endwith %} + {% empty %}{% user_message _("Add content here") %} {% endfor %} {% endwith %}{% spaceless %}
diff --git a/djangocms_frontend/contrib/alert/cms_plugins.py b/djangocms_frontend/contrib/alert/cms_plugins.py index 75194df1..8560d0e7 100644 --- a/djangocms_frontend/contrib/alert/cms_plugins.py +++ b/djangocms_frontend/contrib/alert/cms_plugins.py @@ -3,9 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import SpacingMixin +from ...common import AttributesMixin, ResponsiveMixin, SpacingMixin from .. import alert from . import forms, models diff --git a/djangocms_frontend/contrib/alert/forms.py b/djangocms_frontend/contrib/alert/forms.py index b9a4bd01..65ad4698 100644 --- a/djangocms_frontend/contrib/alert/forms.py +++ b/djangocms_frontend/contrib/alert/forms.py @@ -3,8 +3,7 @@ from entangled.forms import EntangledModelForm from djangocms_frontend import settings -from djangocms_frontend.common.responsive import ResponsiveFormMixin -from djangocms_frontend.common.spacing import SpacingFormMixin +from djangocms_frontend.common import ResponsiveFormMixin, SpacingFormMixin from djangocms_frontend.contrib import alert from djangocms_frontend.fields import ( AttributesFormField, diff --git a/djangocms_frontend/contrib/badge/cms_plugins.py b/djangocms_frontend/contrib/badge/cms_plugins.py index ca5dc927..7f136759 100644 --- a/djangocms_frontend/contrib/badge/cms_plugins.py +++ b/djangocms_frontend/contrib/badge/cms_plugins.py @@ -3,7 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin +from ...common import AttributesMixin from .. import badge from . import forms, models diff --git a/djangocms_frontend/contrib/card/cms_plugins.py b/djangocms_frontend/contrib/card/cms_plugins.py index 4e78b3ec..e686efe7 100644 --- a/djangocms_frontend/contrib/card/cms_plugins.py +++ b/djangocms_frontend/contrib/card/cms_plugins.py @@ -3,10 +3,13 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.background import BackgroundMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import MarginMixin, PaddingMixin +from ...common import ( + AttributesMixin, + BackgroundMixin, + MarginMixin, + PaddingMixin, + ResponsiveMixin, +) from ...helpers import add_plugin from .. import card from . import forms, models diff --git a/djangocms_frontend/contrib/card/forms.py b/djangocms_frontend/contrib/card/forms.py index 2fe3e142..d56c6a98 100644 --- a/djangocms_frontend/contrib/card/forms.py +++ b/djangocms_frontend/contrib/card/forms.py @@ -7,9 +7,12 @@ from djangocms_frontend.settings import COLOR_STYLE_CHOICES, DEVICE_SIZES from ... import settings -from ...common.background import BackgroundFormMixin -from ...common.responsive import ResponsiveFormMixin -from ...common.spacing import MarginFormMixin, PaddingFormMixin +from ...common import ( + BackgroundFormMixin, + MarginFormMixin, + PaddingFormMixin, + ResponsiveFormMixin, +) from ...fields import ( AttributesFormField, ButtonGroup, @@ -87,7 +90,7 @@ class Meta: copy(extra_fields_row_cols), ) -CardLayoutForm.Meta.entangled_fields["config"] += extra_fields_row_cols.keys() +CardLayoutForm._meta.entangled_fields["config"] += extra_fields_row_cols.keys() class CardForm( @@ -222,4 +225,4 @@ class Meta: copy(extra_fields_column), ) -CardDeckForm.Meta.entangled_fields["config"] += extra_fields_column.keys() +CardDeckForm._meta.entangled_fields["config"] += extra_fields_column.keys() diff --git a/djangocms_frontend/contrib/carousel/cms_plugins.py b/djangocms_frontend/contrib/carousel/cms_plugins.py index f5b11a93..421db65d 100644 --- a/djangocms_frontend/contrib/carousel/cms_plugins.py +++ b/djangocms_frontend/contrib/carousel/cms_plugins.py @@ -5,8 +5,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.background import BackgroundMixin +from ...common import AttributesMixin, BackgroundMixin from .. import carousel from ..link.cms_plugins import LinkPluginMixin from . import forms, models @@ -97,7 +96,7 @@ class CarouselSlidePlugin( def get_render_template(self, context, instance, placeholder): return get_plugin_template( - instance.parent.get_plugin_instance()[0], + instance.parent or instance, "carousel", "slide", CAROUSEL_TEMPLATE_CHOICES, diff --git a/djangocms_frontend/contrib/carousel/forms.py b/djangocms_frontend/contrib/carousel/forms.py index 594b99e8..ec2a09e3 100644 --- a/djangocms_frontend/contrib/carousel/forms.py +++ b/djangocms_frontend/contrib/carousel/forms.py @@ -13,7 +13,7 @@ ) from ... import settings -from ...common.background import BackgroundFormMixin +from ...common import BackgroundFormMixin from ...fields import HTMLFormField from ...helpers import first_choice from ...models import FrontendUIItem diff --git a/djangocms_frontend/contrib/carousel/templates/djangocms_frontend/bootstrap5/carousel/default/image.html b/djangocms_frontend/contrib/carousel/templates/djangocms_frontend/bootstrap5/carousel/default/image.html index 3efd3cd5..9d38d0fd 100644 --- a/djangocms_frontend/contrib/carousel/templates/djangocms_frontend/bootstrap5/carousel/default/image.html +++ b/djangocms_frontend/contrib/carousel/templates/djangocms_frontend/bootstrap5/carousel/default/image.html @@ -1,8 +1,6 @@ {% load cms_tags easy_thumbnails_tags %} {% if instance.rel_image %} - {% thumbnail instance.rel_image options.size crop=options.crop upscale=options.upscale subject_location=instance.rel_image.subject_location as thumbnail %} - - {{ instance.rel_image.default_alt_text|default:'' }} + {{ instance.rel_image.default_alt_text|default:'' }} {% else %}
diff --git a/djangocms_frontend/contrib/collapse/cms_plugins.py b/djangocms_frontend/contrib/collapse/cms_plugins.py index 5763d81d..9dc3cb2a 100644 --- a/djangocms_frontend/contrib/collapse/cms_plugins.py +++ b/djangocms_frontend/contrib/collapse/cms_plugins.py @@ -3,7 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin +from ...common import AttributesMixin from .. import collapse from . import forms, models diff --git a/djangocms_frontend/contrib/component/__init__.py b/djangocms_frontend/contrib/component/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_frontend/contrib/component/cms_plugins.py b/djangocms_frontend/contrib/component/cms_plugins.py new file mode 100644 index 00000000..896bcab0 --- /dev/null +++ b/djangocms_frontend/contrib/component/cms_plugins.py @@ -0,0 +1,18 @@ +from cms.plugin_pool import plugin_pool + +# Import the components from the current directory's models module +from .registry import components + +# Loop through the values in the components' registry +for _, plugin, slot_plugins in components._registry.values(): + # Add the plugin to the global namespace + globals()[plugin.__name__] = plugin + # Register the plugin with the plugin pool + plugin_pool.register_plugin(plugin) + + # Loop through the slot plugins associated with the current plugin + for slot_plugin in slot_plugins: + # Add the slot plugin to the global namespace + globals()[slot_plugin.__name__] = slot_plugin + # Register the slot plugin with the plugin pool + plugin_pool.register_plugin(slot_plugin) diff --git a/djangocms_frontend/contrib/component/components.py b/djangocms_frontend/contrib/component/components.py new file mode 100644 index 00000000..f8d4d603 --- /dev/null +++ b/djangocms_frontend/contrib/component/components.py @@ -0,0 +1,213 @@ +import importlib +import typing + +from cms.api import add_plugin +from cms.plugin_base import CMSPluginBase +from django import forms +from django.apps import apps +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ +from entangled.forms import EntangledModelForm + + +def _get_mixin_classes(mixins: list, suffix: str = "") -> list[type]: + """Find and import mixin classes from a list of mixin strings""" + mixins = [ + (mixin.rsplit(".")[0], f"{mixin.rsplit('.')[-1]}{suffix}Mixin") + if "." in mixin + else ("djangocms_frontend.common", f"{mixin}{suffix}Mixin") + for mixin in mixins + ] + return [importlib.import_module(module).__dict__[name] for module, name in mixins] + + +class Slot: + """Slat class as syntactic surgar to more easily define slot plugins""" + + def __init__(self, name, verbose_name, **kwargs): + self.name = name + self.verbose_name = verbose_name + self.kwargs = kwargs + + +class CMSFrontendComponent(forms.Form): + """Base class for frontend components:""" + + slot_template = "djangocms_frontend/slot.html" + _base_form = EntangledModelForm + _plugin_mixins = [] + _model_mixins = [] + _admin_form = None + _model = None + _plugin = None + + @classmethod + def admin_form_factory(cls, **kwargs) -> type: + if cls._admin_form is None: + from djangocms_frontend.models import FrontendUIItem + + mixins = getattr(cls._component_meta, "mixins", []) + mixins = _get_mixin_classes(mixins, "Form") + cls._admin_form = type( + f"{cls.__name__}Form", + ( + *mixins, + cls, + cls._base_form, + ), + { + **kwargs, + "Meta": type( + "Meta", + (), + { + "model": FrontendUIItem, + "entangled_fields": { + "config": list(cls.declared_fields.keys()), + }, + }, + ), + }, + ) + return cls._admin_form + + @classmethod + def get_slot_plugins(cls) -> dict[str:str]: + slots: list[Slot] = [ + slot if isinstance(slot, Slot) else Slot(*slot) for slot in getattr(cls._component_meta, "slots", []) + ] + return {f"{cls.__name__}{slot.name.capitalize()}Plugin": slot for slot in slots} + + @classmethod + def plugin_model_factory(cls) -> type: + if cls._model is None: + from djangocms_frontend.models import FrontendUIItem + + app_config = apps.get_containing_app_config(cls.__module__) + if app_config is None: + raise ValueError(f"Cannot find app_config for {cls.__module__}") + cls._model = type( + cls.__name__, + ( + *cls._model_mixins, + FrontendUIItem, + ), + { + "Meta": type( + "Meta", + (), + { + "app_label": app_config.label, + "proxy": True, + "managed": False, + "verbose_name": getattr(cls._component_meta, "name", cls.__name__), + }, + ), + "get_short_description": cls.get_short_description, + "__module__": "djangocms_frontend.contrib.component.models", + }, + ) + return cls._model + + @classmethod + def plugin_factory(cls) -> type: + if cls._plugin is None: + from djangocms_frontend.cms_plugins import CMSUIPlugin + + mixins = getattr(cls._component_meta, "mixins", []) + slots = cls.get_slot_plugins() + mixins = _get_mixin_classes(mixins) + + cls._plugin = type( + cls.__name__ + "Plugin", + ( + *mixins, + *cls._plugin_mixins, + CMSUIPlugin, + ), + { + "name": getattr(cls._component_meta, "name", cls.__name__), + "module": getattr(cls._component_meta, "module", _("Component")), + "model": cls.plugin_model_factory(), + "form": cls.admin_form_factory(), + "allow_children": getattr(cls._component_meta, "allow_children", False) or slots, + "child_classes": getattr(cls._component_meta, "child_classes", []) + list(slots.keys()), + "render_template": getattr(cls._component_meta, "render_template", CMSUIPlugin.render_template), + "fieldsets": getattr(cls, "fieldsets", cls._generate_fieldset()), + "change_form_template": "djangocms_frontend/admin/base.html", + "slots": slots, + "frontend_editable_fields": getattr(cls._component_meta, "frontend_editable_fields", []), + "save_model": cls.save_model, + "link_fieldset_position": getattr(cls._component_meta, "link_fieldset_position", 1), + **( + { + "get_render_template": cls.get_render_template, + "TEMPLATES": cls.TEMPLATES, + } + if hasattr(cls, "get_render_template") + else {} + ), + }, + ) + return cls._plugin + + @classmethod + def slot_plugin_factory(cls) -> list[type]: + slots = cls.get_slot_plugins() + return [ + type( + name, + (CMSPluginBase,), + { + "name": force_str(slot.verbose_name), + "module": getattr(cls._component_meta, "module", _("Component")), + "allow_children": True, + "edit_disabled": True, + "parent_classes": cls.__name__ + "Plugin", + "render_template": cls.slot_template, + **slot.kwargs, + }, + ) + for name, slot in slots.items() + ] + + @classmethod + def get_registration(cls) -> tuple[type, type, list[type]]: + return ( + cls.plugin_model_factory(), + cls.plugin_factory(), + cls.slot_plugin_factory(), + ) + + @classmethod + @property + def _component_meta(cls) -> typing.Optional[type]: + if hasattr(cls, "Meta"): + return cls.Meta + return None + + @classmethod + def _generate_fieldset(cls) -> list[tuple[typing.Optional[str], dict]]: + return [(None, {"fields": cls.declared_fields.keys()})] + + def get_short_description(self) -> str: + return self.config.get("title", "") + + def save_model(self, request, obj, form: forms.Form, change: bool) -> None: + """Auto-createas slot plugins upon creation of component plugin instance""" + from djangocms_frontend.cms_plugins import CMSUIPlugin + + super(CMSUIPlugin, self).save_model(request, obj, form, change) + if not change: + for slot in self.slots.keys(): + add_plugin(obj.placeholder, slot, obj.language, target=obj) + + +class ComponentLinkMixin: + from djangocms_frontend.contrib.link.cms_plugins import LinkPluginMixin + from djangocms_frontend.contrib.link.forms import AbstractLinkForm + from djangocms_frontend.contrib.link.helpers import GetLinkMixin + + _base_form = AbstractLinkForm + _model_mixins = [GetLinkMixin] + _plugin_mixins = [LinkPluginMixin] diff --git a/djangocms_frontend/contrib/component/registry.py b/djangocms_frontend/contrib/component/registry.py new file mode 100644 index 00000000..95c8fb8d --- /dev/null +++ b/djangocms_frontend/contrib/component/registry.py @@ -0,0 +1,25 @@ +import warnings + +from django.utils.module_loading import autodiscover_modules + + +class Components: + _registry: dict = {} + _discovered: bool = False + + def register(self, component): + if component.__name__ in self._registry: + warnings.warn(f"Component {component.__name__} already registered", stacklevel=2) + return component + self._registry[component.__name__] = component.get_registration() + return component + + def __getitem__(self, item): + return self._registry[item] + + +components = Components() + +if not components._discovered: + autodiscover_modules("cms_components", register_to=components) + components._discovered = True diff --git a/djangocms_frontend/contrib/component/templates/djangocms_frontend/slot.html b/djangocms_frontend/contrib/component/templates/djangocms_frontend/slot.html new file mode 100644 index 00000000..802deeed --- /dev/null +++ b/djangocms_frontend/contrib/component/templates/djangocms_frontend/slot.html @@ -0,0 +1 @@ +{% load frontend %}{% childplugins instance %}{% endchildplugins %} diff --git a/djangocms_frontend/contrib/content/cms_plugins.py b/djangocms_frontend/contrib/content/cms_plugins.py index 713a3e95..a43c73d7 100644 --- a/djangocms_frontend/contrib/content/cms_plugins.py +++ b/djangocms_frontend/contrib/content/cms_plugins.py @@ -3,10 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.background import BackgroundMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import SpacingMixin +from ...common import AttributesMixin, BackgroundMixin, ResponsiveMixin, SpacingMixin from .. import content from . import forms, models diff --git a/djangocms_frontend/contrib/content/forms.py b/djangocms_frontend/contrib/content/forms.py index c3516abc..2dfcccfc 100644 --- a/djangocms_frontend/contrib/content/forms.py +++ b/djangocms_frontend/contrib/content/forms.py @@ -6,9 +6,7 @@ from djangocms_frontend.settings import ALIGN_CHOICES from ... import settings -from ...common.background import BackgroundFormMixin -from ...common.responsive import ResponsiveFormMixin -from ...common.spacing import SpacingFormMixin +from ...common import BackgroundFormMixin, ResponsiveFormMixin, SpacingFormMixin from ...fields import AttributesFormField, HTMLFormField, IconGroup, TagTypeFormField from ...helpers import first_choice from ...models import FrontendUIItem diff --git a/djangocms_frontend/contrib/grid/cms_plugins.py b/djangocms_frontend/contrib/grid/cms_plugins.py index ac6784b5..26c01f8a 100644 --- a/djangocms_frontend/contrib/grid/cms_plugins.py +++ b/djangocms_frontend/contrib/grid/cms_plugins.py @@ -2,14 +2,16 @@ from django.utils.translation import gettext_lazy as _ from djangocms_frontend import settings -from djangocms_frontend.common.attributes import AttributesMixin -from djangocms_frontend.common.background import BackgroundMixin -from djangocms_frontend.common.responsive import ResponsiveMixin -from djangocms_frontend.common.sizing import SizingMixin -from djangocms_frontend.common.spacing import SpacingMixin +from djangocms_frontend.common import ( + AttributesMixin, + BackgroundMixin, + ResponsiveMixin, + SizingMixin, + SpacingMixin, +) from ...cms_plugins import CMSUIPlugin -from ...common.title import TitleMixin +from ...common import TitleMixin from ...helpers import add_plugin from .. import grid from . import forms, models @@ -153,9 +155,10 @@ class GridColumnPlugin( change_form_template = "djangocms_frontend/admin/grid_column.html" allow_children = True require_parent = True - # TODO it should allow for the responsive utilitiy class + # TODO it should allow for the responsive utility class # https://getbootstrap.com/docs/5.0/layout/grid/#column-resets parent_classes = ["GridRowPlugin"] + edit_disabled = True fieldsets = [ ( diff --git a/djangocms_frontend/contrib/grid/forms.py b/djangocms_frontend/contrib/grid/forms.py index 2504c160..5772fbdd 100644 --- a/djangocms_frontend/contrib/grid/forms.py +++ b/djangocms_frontend/contrib/grid/forms.py @@ -6,10 +6,12 @@ from entangled.forms import EntangledModelForm from djangocms_frontend import settings -from djangocms_frontend.common.background import BackgroundFormMixin -from djangocms_frontend.common.responsive import ResponsiveFormMixin -from djangocms_frontend.common.sizing import SizingFormMixin -from djangocms_frontend.common.spacing import SpacingFormMixin +from djangocms_frontend.common import ( + BackgroundFormMixin, + ResponsiveFormMixin, + SizingFormMixin, + SpacingFormMixin, +) from djangocms_frontend.fields import ( AttributesFormField, AutoNumberInput, @@ -135,14 +137,16 @@ class Meta: max_value=GRID_SIZE, ) + +GridRowBaseForm.Meta.entangled_fields["config"] += extra_fields_column.keys() + + GridRowForm = type( "GridRowBaseForm", (GridRowBaseForm,), copy(extra_fields_column), ) -GridRowForm.Meta.entangled_fields["config"] += extra_fields_column.keys() - class GridColumnBaseForm( mixin_factory("GridColumn"), @@ -239,4 +243,4 @@ def clean(self): copy(extra_fields_column), ) -GridColumnForm.Meta.entangled_fields["config"] += extra_fields_column.keys() +GridColumnForm._meta.entangled_fields["config"] += extra_fields_column.keys() diff --git a/djangocms_frontend/contrib/grid/frameworks/bootstrap5.py b/djangocms_frontend/contrib/grid/frameworks/bootstrap5.py index 8a263874..28b13000 100644 --- a/djangocms_frontend/contrib/grid/frameworks/bootstrap5.py +++ b/djangocms_frontend/contrib/grid/frameworks/bootstrap5.py @@ -25,8 +25,8 @@ class GridRowRenderMixin: def render(self, context, instance, placeholder): instance.add_classes( "row", - instance.vertical_alignment, - instance.horizontal_alignment, + instance.config.get("vertical_alignment"), + instance.config.get("horizontal_alignment"), ) if instance.parent and instance.parent.plugin_type == "CardPlugin": instance.add_classes("g-0") # no gutters if inside card @@ -62,6 +62,6 @@ def render(self, context, instance, placeholder): instance.add_classes( f"col text-{instance.text_alignment}" if instance.config.get("text_alignment", None) else "col" ) - instance.add_classes(instance.column_alignment) + instance.add_classes(instance.config.get("column_alignment")) instance.add_classes(get_grid_values(instance)) return super().render(context, instance, placeholder) diff --git a/djangocms_frontend/contrib/grid/models.py b/djangocms_frontend/contrib/grid/models.py index 2a4030fb..13ec3100 100644 --- a/djangocms_frontend/contrib/grid/models.py +++ b/djangocms_frontend/contrib/grid/models.py @@ -1,5 +1,3 @@ -from ...helpers import get_related_object - try: from functools import cached_property except ImportError: # Only available since Python 3.8 @@ -13,11 +11,11 @@ from .constants import GRID_CONTAINER_CHOICES -class TitelModelMixin: +class TitleModelMixin: pass -class GridContainer(TitelModelMixin, FrontendUIItem): +class GridContainer(TitleModelMixin, FrontendUIItem): """ Layout > Grid: "Container" Plugin https://getbootstrap.com/docs/5.0/layout/grid/ @@ -35,14 +33,8 @@ def get_short_description(self): text += f" ({item[1]})" return text - @cached_property - def image(self): - if getattr(self, "container_image", False): - return get_related_object(self.config, "container_image") - return None - -class GridRow(TitelModelMixin, FrontendUIItem): +class GridRow(TitleModelMixin, FrontendUIItem): """ Layout > Grid: "Row" Plugin https://getbootstrap.com/docs/5.0/layout/grid/ @@ -76,7 +68,7 @@ class Meta: def get_short_description(self): text = self.config.get("plugin_title", {}).get("title", "") or self.config.get("attributes", {}).get("id", "") - if self.xs_col: + if self.config.get('xs_col'): text += f" (col-{self.xs_col}) " else: text += " (auto) " diff --git a/djangocms_frontend/contrib/icon/cms_plugins.py b/djangocms_frontend/contrib/icon/cms_plugins.py index 0fba9356..9b6077bc 100644 --- a/djangocms_frontend/contrib/icon/cms_plugins.py +++ b/djangocms_frontend/contrib/icon/cms_plugins.py @@ -3,10 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.background import BackgroundMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import SpacingMixin +from ...common import AttributesMixin, BackgroundMixin, ResponsiveMixin, SpacingMixin from .. import icon from . import forms, models diff --git a/djangocms_frontend/contrib/icon/forms.py b/djangocms_frontend/contrib/icon/forms.py index 3101196f..526b3f6c 100644 --- a/djangocms_frontend/contrib/icon/forms.py +++ b/djangocms_frontend/contrib/icon/forms.py @@ -9,9 +9,7 @@ ) from ... import settings -from ...common.background import BackgroundFormMixin -from ...common.responsive import ResponsiveFormMixin -from ...common.spacing import SpacingFormMixin +from ...common import BackgroundFormMixin, ResponsiveFormMixin, SpacingFormMixin from ...helpers import first_choice from ...models import FrontendUIItem from ...settings import COLOR_STYLE_CHOICES diff --git a/djangocms_frontend/contrib/icon/templatetags/icon_tags.py b/djangocms_frontend/contrib/icon/templatetags/icon_tags.py index a7136bc3..956e7b83 100644 --- a/djangocms_frontend/contrib/icon/templatetags/icon_tags.py +++ b/djangocms_frontend/contrib/icon/templatetags/icon_tags.py @@ -1,5 +1,6 @@ from django import template from django.templatetags.static import static +from django.utils.safestring import mark_safe from djangocms_frontend.contrib.icon.conf import ICON_LIBRARIES @@ -15,3 +16,12 @@ def add_css_for_icon(context, icon): css_link = static(f"djangocms_frontend/icon/vendor/assets/stylesheets/{css_link}") context["icon_css"] = css_link return context + + +@register.simple_tag(takes_context=True) +def icon(context, icon): + if icon: + icon_class = icon.get("iconClass", "") + icon_text = icon.get("iconText", "") + return mark_safe(f'{icon_text}') + return "" diff --git a/djangocms_frontend/contrib/image/cms_plugins.py b/djangocms_frontend/contrib/image/cms_plugins.py index 4fd8182e..dec1d97a 100644 --- a/djangocms_frontend/contrib/image/cms_plugins.py +++ b/djangocms_frontend/contrib/image/cms_plugins.py @@ -3,9 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import MarginMixin +from ...common import AttributesMixin, MarginMixin, ResponsiveMixin from .. import image from ..link.cms_plugins import LinkPluginMixin from . import forms, models @@ -90,6 +88,13 @@ def get_render_template(self, context, instance, placeholder): return f"djangocms_frontend/{settings.framework}/{instance.template}/image.html" def render(self, context, instance, placeholder): + # assign link to a context variable to be performant + context["picture_link"] = instance.get_link() + context["picture_size"] = instance.get_size( + width=context.get("width", 0), + height=context.get("height", 0), + ) + context["img_srcset_data"] = instance.img_srcset_data if instance.config.get("lazy_loading", False): instance.add_attribute("loading", "lazy") return super().render(context, instance, placeholder) diff --git a/djangocms_frontend/contrib/image/fields.py b/djangocms_frontend/contrib/image/fields.py new file mode 100644 index 00000000..1796b627 --- /dev/null +++ b/djangocms_frontend/contrib/image/fields.py @@ -0,0 +1,11 @@ +from django.db.models import ManyToOneRel +from filer.fields.image import AdminImageFormField, FilerImageField +from filer.models import Image + + +class ImageFormField(AdminImageFormField): + def __init__(self, *args, **kwargs): + kwargs.setdefault("rel", ManyToOneRel(FilerImageField, Image, "id")) + kwargs.setdefault("queryset", Image.objects.all()) + kwargs.setdefault("to_field_name", "id") + super().__init__(*args, **kwargs) diff --git a/djangocms_frontend/contrib/image/forms.py b/djangocms_frontend/contrib/image/forms.py index 432675fe..0a1043e1 100644 --- a/djangocms_frontend/contrib/image/forms.py +++ b/djangocms_frontend/contrib/image/forms.py @@ -8,8 +8,7 @@ from djangocms_frontend import settings -from ...common.responsive import ResponsiveFormMixin -from ...common.spacing import MarginFormMixin +from ...common import MarginFormMixin, ResponsiveFormMixin from ...fields import AttributesFormField, TagTypeFormField, TemplateChoiceMixin from ...helpers import first_choice from ...models import FrontendUIItem @@ -96,6 +95,7 @@ class Meta: "attributes", ] } + exclude = ("ui_item",) link_is_optional = True diff --git a/djangocms_frontend/contrib/image/frameworks/bootstrap5.py b/djangocms_frontend/contrib/image/frameworks/bootstrap5.py index fa547064..e48f5f83 100644 --- a/djangocms_frontend/contrib/image/frameworks/bootstrap5.py +++ b/djangocms_frontend/contrib/image/frameworks/bootstrap5.py @@ -3,13 +3,6 @@ class ImageRenderMixin: def render(self, context, instance, placeholder): - # assign link to a context variable to be performant - context["picture_link"] = instance.get_link() - context["picture_size"] = instance.get_size( - width=context.get("width", 0), - height=context.get("height", 0), - ) - context["img_srcset_data"] = instance.img_srcset_data if instance.alignment: # See https://getbootstrap.com/docs/5.2/content/images/#aligning-images if instance.alignment != "center": diff --git a/djangocms_frontend/contrib/jumbotron/cms_plugins.py b/djangocms_frontend/contrib/jumbotron/cms_plugins.py index e16de09e..91df1206 100644 --- a/djangocms_frontend/contrib/jumbotron/cms_plugins.py +++ b/djangocms_frontend/contrib/jumbotron/cms_plugins.py @@ -3,10 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.background import BackgroundMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import SpacingMixin +from ...common import AttributesMixin, BackgroundMixin, ResponsiveMixin, SpacingMixin from ...helpers import get_plugin_template from .. import jumbotron from . import forms, models diff --git a/djangocms_frontend/contrib/jumbotron/forms.py b/djangocms_frontend/contrib/jumbotron/forms.py index ec47eaac..0eb2943a 100644 --- a/djangocms_frontend/contrib/jumbotron/forms.py +++ b/djangocms_frontend/contrib/jumbotron/forms.py @@ -3,10 +3,13 @@ from entangled.forms import EntangledModelForm from djangocms_frontend import settings -from djangocms_frontend.common.background import BackgroundFormMixin -from djangocms_frontend.common.responsive import ResponsiveFormMixin -from djangocms_frontend.common.spacing import SpacingFormMixin +from djangocms_frontend.common import ( + BackgroundFormMixin, + ResponsiveFormMixin, + SpacingFormMixin, +) from djangocms_frontend.contrib import jumbotron +from djangocms_frontend.contrib.jumbotron import models from djangocms_frontend.fields import ( AttributesFormField, TagTypeFormField, @@ -31,6 +34,7 @@ class JumbotronForm( """ class Meta: + model = models.Jumbotron entangled_fields = { "config": [ "jumbotron_fluid", diff --git a/djangocms_frontend/contrib/link/__init__.py b/djangocms_frontend/contrib/link/__init__.py index 9d751698..e69de29b 100644 --- a/djangocms_frontend/contrib/link/__init__.py +++ b/djangocms_frontend/contrib/link/__init__.py @@ -1,11 +0,0 @@ -class FormsSite: - @property - def urls(self): - from .urls import urls - - return urls - - -site = FormsSite() - -__all__ = ["site"] diff --git a/djangocms_frontend/contrib/link/cms_plugins.py b/djangocms_frontend/contrib/link/cms_plugins.py index 16c9b622..dd6c0564 100644 --- a/djangocms_frontend/contrib/link/cms_plugins.py +++ b/djangocms_frontend/contrib/link/cms_plugins.py @@ -8,8 +8,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.spacing import SpacingMixin +from ...common import AttributesMixin, SpacingMixin from .. import link from . import forms, models, views from .constants import USE_LINK_ICONS @@ -63,6 +62,11 @@ class LinkPluginMixin: ) ) + def render(self, context, instance, placeholder): + if "request" in context: + instance._cms_page = getattr(context["request"], "current_page", None) + return super().render(context, instance, placeholder) + def get_form(self, request, obj=None, change=False, **kwargs): """The link form needs the request object to check permissions""" form = super().get_form(request, obj, change, **kwargs) diff --git a/djangocms_frontend/contrib/link/forms.py b/djangocms_frontend/contrib/link/forms.py index 45f56f49..f7e36efd 100644 --- a/djangocms_frontend/contrib/link/forms.py +++ b/djangocms_frontend/contrib/link/forms.py @@ -20,7 +20,7 @@ from filer.models import File from ... import settings -from ...common.spacing import SpacingFormMixin +from ...common import SpacingFormMixin from ...fields import ( AttributesFormField, ButtonGroup, @@ -321,6 +321,7 @@ class Meta: ] } untangled_fields = () + exclude = ("ui_item",) name = forms.CharField( label=_("Display name"), diff --git a/djangocms_frontend/contrib/link/frameworks/bootstrap5.py b/djangocms_frontend/contrib/link/frameworks/bootstrap5.py index 09bc0830..1123d0c4 100644 --- a/djangocms_frontend/contrib/link/frameworks/bootstrap5.py +++ b/djangocms_frontend/contrib/link/frameworks/bootstrap5.py @@ -18,7 +18,7 @@ def render(self, context, instance, placeholder): link_classes.append(f"link-{instance.link_context}") else: link_classes.append("btn") - if not instance.link_outline: + if not instance.config.get("link_outline"): link_classes.append(f"{background_prefix}-{instance.link_context}") else: link_classes.append(f"btn-outline-{instance.link_context}") diff --git a/djangocms_frontend/contrib/link/frameworks/tailwind.py b/djangocms_frontend/contrib/link/frameworks/tailwind.py new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_frontend/contrib/link/helpers.py b/djangocms_frontend/contrib/link/helpers.py index 94e1dc3b..d71c587c 100644 --- a/djangocms_frontend/contrib/link/helpers.py +++ b/djangocms_frontend/contrib/link/helpers.py @@ -5,9 +5,12 @@ from django.conf import settings as django_settings from django.contrib.admin import site from django.contrib.contenttypes.models import ContentType +from django.contrib.sites.models import Site from django.core.exceptions import FieldError, ObjectDoesNotExist from django.utils.encoding import force_str +from djangocms_frontend.helpers import get_related_object + LINK_MODELS = getattr(django_settings, "DJANGOCMS_FRONTEND_LINK_MODELS", []) @@ -60,12 +63,14 @@ def get_object_for_value(value): def unescape(text, nbsp): - return (text.replace(" ", nbsp) - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", '"') - .replace("'", "'")) + return ( + text.replace(" ", nbsp) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'") + ) def get_link_choices(request, term="", lang=None, nbsp=None): @@ -130,3 +135,102 @@ def to_choices(json): ) return to_choices(get_link_choices(request, term, lang, " ")) + + +class GetLinkMixin: + def __init__(self, *args, **kwargs): + self._cms_page = None + super().__init__(*args, **kwargs) + + def get_link(self): + if getattr(self, "url_grouper", None): + url_grouper = get_related_object(self.config, "url_grouper") + if not url_grouper: + return "" + # The next line is a workaround, since djangocms-url-manager does not provide a way of + # getting the current URL object. + from djangocms_url_manager.models import Url + url = Url._base_manager.filter(url_grouper=url_grouper).order_by("pk").last() + if not url: # pragma: no cover + return "" + # simulate the call to the unauthorized CMSPlugin.page property + cms_page = self.placeholder.page if self.placeholder_id else None + + # first, we check if the placeholder the plugin is attached to + # has a page. Thus, the check "is not None": + if cms_page is not None: + if getattr(cms_page, "node", None): + cms_page_site_id = getattr(cms_page.node, "site_id", None) + else: + cms_page_site_id = getattr(cms_page, "site_id", None) + # a plugin might not be attached to a page and thus has no site + # associated with it. This also applies to plugins inside + # static placeholders + else: + cms_page_site_id = None + return url.get_url(cms_page_site_id) or "" + + if getattr(self, "internal_link", None): + try: + ref_page = get_related_object(self.config, "internal_link") + link = ref_page.get_absolute_url() + except ( + KeyError, + TypeError, + ValueError, + AttributeError, + ObjectDoesNotExist, + ): + self.internal_link = None + return "" + + # simulate the call to the unauthorized CMSPlugin.page property + cms_page = self._cms_page or self.placeholder.page if self.placeholder_id else None + + # first, we check if the placeholder the plugin is attached to + # has a page. Thus, the check "is not None": + if cms_page is not None: + if getattr(cms_page, "node", None): + cms_page_site_id = getattr(cms_page.node, "site_id", None) + else: + cms_page_site_id = getattr(cms_page, "site_id", None) + # a plugin might not be attached to a page and thus has no site + # associated with it. This also applies to plugins inside + # static placeholders + else: + cms_page_site_id = None + + # now we do the same for the reference page the plugin links to + # in order to compare them later + if getattr(ref_page, "node", None): + ref_page_site_id = ref_page.node.site_id + elif getattr(ref_page, "site_id", None): + ref_page_site_id = ref_page.site_id + # if no external reference is found the plugin links to the + # current page + else: + ref_page_site_id = Site.objects.get_current().pk + + if ref_page_site_id != cms_page_site_id: + ref_site = Site.objects._get_site_by_id(ref_page_site_id).domain + link = f"//{ref_site}{link}" + + elif getattr(self, "file_link", None): + link = getattr(get_related_object(self.config, "file_link"), "url", "") + + elif getattr(self, "external_link", None): + link = self.external_link + + elif getattr(self, "phone", None): + link = "tel:{}".format(self.phone.replace(" ", "")) + + elif getattr(self, "mailto", None): + link = f"mailto:{self.mailto}" + + else: + link = "" + + if (not getattr(self, "phone", None) and not getattr(self, "mailto", None)) and getattr(self, "anchor", None): + link += f"#{self.anchor}" + + return link diff --git a/djangocms_frontend/contrib/link/models.py b/djangocms_frontend/contrib/link/models.py index fe1359ed..0a8df3f6 100644 --- a/djangocms_frontend/contrib/link/models.py +++ b/djangocms_frontend/contrib/link/models.py @@ -1,8 +1,6 @@ -from django.contrib.sites.models import Site -from django.db import models from django.utils.translation import gettext as _ -from djangocms_frontend.helpers import get_related_object +from djangocms_frontend.contrib.link.helpers import GetLinkMixin # 'link' type is added manually as it is only required for this plugin from djangocms_frontend.models import FrontendUIItem @@ -11,96 +9,6 @@ COLOR_STYLE_CHOICES = (("link", _("Link")),) + COLOR_STYLE_CHOICES -class GetLinkMixin: - def get_link(self): - if getattr(self, "url_grouper", None): - url_grouper = get_related_object(self.config, "url_grouper") - if not url_grouper: - return "" - url = url_grouper.get_content(show_draft_content=True) - # simulate the call to the unauthorized CMSPlugin.page property - cms_page = self.placeholder.page if self.placeholder_id else None - - # first, we check if the placeholder the plugin is attached to - # has a page. Thus the check "is not None": - if cms_page is not None: - if getattr(cms_page, "node", None): - cms_page_site_id = getattr(cms_page.node, "site_id", None) - else: - cms_page_site_id = getattr(cms_page, "site_id", None) - # a plugin might not be attached to a page and thus has no site - # associated with it. This also applies to plugins inside - # static placeholders - else: - cms_page_site_id = None - return url.get_url(cms_page_site_id) or "" - - if getattr(self, "internal_link", None): - try: - ref_page = get_related_object(self.config, "internal_link") - link = ref_page.get_absolute_url() - except ( - KeyError, - TypeError, - ValueError, - AttributeError, - models.ObjectDoesNotExist, - ): - self.internal_link = None - return "" - - # simulate the call to the unauthorized CMSPlugin.page property - cms_page = self.placeholder.page if self.placeholder_id else None - - # first, we check if the placeholder the plugin is attached to - # has a page. Thus, the check "is not None": - if cms_page is not None: - if getattr(cms_page, "node", None): - cms_page_site_id = getattr(cms_page.node, "site_id", None) - else: - cms_page_site_id = getattr(cms_page, "site_id", None) - # a plugin might not be attached to a page and thus has no site - # associated with it. This also applies to plugins inside - # static placeholders - else: - cms_page_site_id = None - - # now we do the same for the reference page the plugin links to - # in order to compare them later - if getattr(ref_page, "node", None): - ref_page_site_id = ref_page.node.site_id - elif getattr(ref_page, "site_id", None): - ref_page_site_id = ref_page.site_id - # if no external reference is found the plugin links to the - # current page - else: - ref_page_site_id = Site.objects.get_current().pk - - if ref_page_site_id != cms_page_site_id: - ref_site = Site.objects._get_site_by_id(ref_page_site_id).domain - link = f"//{ref_site}{link}" - - elif getattr(self, "file_link", None): - link = getattr(get_related_object(self.config, "file_link"), "url", "") - - elif getattr(self, "external_link", None): - link = self.external_link - - elif getattr(self, "phone", None): - link = "tel:{}".format(self.phone.replace(" ", "")) - - elif getattr(self, "mailto", None): - link = f"mailto:{self.mailto}" - - else: - link = "" - - if (not getattr(self, "phone", None) and not getattr(self, "mailto", None)) and getattr(self, "anchor", None): - link += f"#{self.anchor}" - - return link - - class Link(GetLinkMixin, FrontendUIItem): """ Components > "Button" Plugin diff --git a/djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/icon.html b/djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/icon.html new file mode 100644 index 00000000..10db3950 --- /dev/null +++ b/djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/icon.html @@ -0,0 +1,2 @@ +{% if "iconClass" in icon_class %}{% load icon_tags %}{% add_css_for_icon icon_class %}{{ icon_class.iconText }} +{% else %}{% endif %} diff --git a/djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/link.html b/djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/link.html new file mode 100644 index 00000000..073eba03 --- /dev/null +++ b/djangocms_frontend/contrib/link/templates/djangocms_frontend/tailwind/link/default/link.html @@ -0,0 +1 @@ +{% load cms_tags frontend %}{% if link %}{% endif %}{% if instance.icon_left %}{% include "djangocms_frontend/bootstrap5/link/default/icon.html" with icon_class=instance.icon_left attribute_class="me-1" %}{% endif %}{% for plugin in instance.child_plugin_instances %}{% render_plugin plugin %}{% empty %}{{ instance.name }}{% endfor %}{% if instance.icon_right %}{% include "djangocms_frontend/bootstrap5/link/default/icon.html" with icon_class=instance.icon_right attribute_class="ms-1" %}{% endif %}{% if link %}{% endif %} diff --git a/djangocms_frontend/contrib/listgroup/cms_plugins.py b/djangocms_frontend/contrib/listgroup/cms_plugins.py index dfbc12f0..a9f38b09 100644 --- a/djangocms_frontend/contrib/listgroup/cms_plugins.py +++ b/djangocms_frontend/contrib/listgroup/cms_plugins.py @@ -3,9 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.responsive import ResponsiveMixin -from ...common.spacing import MarginMixin, PaddingMixin +from ...common import AttributesMixin, MarginMixin, PaddingMixin, ResponsiveMixin from .. import listgroup from . import forms, models diff --git a/djangocms_frontend/contrib/listgroup/forms.py b/djangocms_frontend/contrib/listgroup/forms.py index 85eec0e6..cb97cd24 100644 --- a/djangocms_frontend/contrib/listgroup/forms.py +++ b/djangocms_frontend/contrib/listgroup/forms.py @@ -4,8 +4,7 @@ from djangocms_frontend import settings -from ...common.responsive import ResponsiveFormMixin -from ...common.spacing import MarginFormMixin, PaddingFormMixin +from ...common import MarginFormMixin, PaddingFormMixin, ResponsiveFormMixin from ...fields import ( AttributesFormField, ButtonGroup, diff --git a/djangocms_frontend/contrib/media/cms_plugins.py b/djangocms_frontend/contrib/media/cms_plugins.py index 979c3198..23785ac4 100644 --- a/djangocms_frontend/contrib/media/cms_plugins.py +++ b/djangocms_frontend/contrib/media/cms_plugins.py @@ -3,8 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.responsive import ResponsiveMixin +from ...common import AttributesMixin, ResponsiveMixin from .. import media from . import forms, models diff --git a/djangocms_frontend/contrib/media/forms.py b/djangocms_frontend/contrib/media/forms.py index 0c9289f7..b8bee89c 100644 --- a/djangocms_frontend/contrib/media/forms.py +++ b/djangocms_frontend/contrib/media/forms.py @@ -2,7 +2,7 @@ from djangocms_frontend.fields import AttributesFormField, TagTypeFormField -from ...common.responsive import ResponsiveFormMixin +from ...common import ResponsiveFormMixin from ...models import FrontendUIItem diff --git a/djangocms_frontend/contrib/navigation/cms_plugins.py b/djangocms_frontend/contrib/navigation/cms_plugins.py index 999fe948..fb98a07b 100644 --- a/djangocms_frontend/contrib/navigation/cms_plugins.py +++ b/djangocms_frontend/contrib/navigation/cms_plugins.py @@ -3,8 +3,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.background import BackgroundMixin +from ...common import AttributesMixin, BackgroundMixin from ...helpers import first_choice, get_plugin_template, get_template_path from .. import navigation from ..link.cms_plugins import LinkPluginMixin, TextLinkPlugin diff --git a/djangocms_frontend/contrib/navigation/forms.py b/djangocms_frontend/contrib/navigation/forms.py index 611d5d3c..67459dd3 100644 --- a/djangocms_frontend/contrib/navigation/forms.py +++ b/djangocms_frontend/contrib/navigation/forms.py @@ -3,7 +3,7 @@ from entangled.forms import EntangledModelForm from djangocms_frontend import settings -from djangocms_frontend.common.background import BackgroundFormMixin +from djangocms_frontend.common import BackgroundFormMixin from djangocms_frontend.contrib import navigation from djangocms_frontend.contrib.link.forms import AbstractLinkForm, LinkForm from djangocms_frontend.fields import ( diff --git a/djangocms_frontend/contrib/tabs/cms_plugins.py b/djangocms_frontend/contrib/tabs/cms_plugins.py index 5969aa59..431c972a 100644 --- a/djangocms_frontend/contrib/tabs/cms_plugins.py +++ b/djangocms_frontend/contrib/tabs/cms_plugins.py @@ -5,8 +5,7 @@ from ... import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.spacing import PaddingMixin +from ...common import AttributesMixin, PaddingMixin from .. import tabs from . import forms, models from .constants import TAB_TEMPLATE_CHOICES @@ -70,7 +69,7 @@ class TabItemPlugin(mixin_factory("TabItem"), AttributesMixin, PaddingMixin, CMS def get_render_template(self, context, instance, placeholder): return get_plugin_template( - instance.parent.get_plugin_instance()[0], + instance.parent or instance, "tabs", "item", TAB_TEMPLATE_CHOICES, diff --git a/djangocms_frontend/contrib/tabs/forms.py b/djangocms_frontend/contrib/tabs/forms.py index 4daccaa7..29dd825b 100644 --- a/djangocms_frontend/contrib/tabs/forms.py +++ b/djangocms_frontend/contrib/tabs/forms.py @@ -3,7 +3,7 @@ from entangled.forms import EntangledModelForm from ... import settings -from ...common.spacing import PaddingFormMixin +from ...common import PaddingFormMixin from ...fields import ( AttributesFormField, ButtonGroup, diff --git a/djangocms_frontend/contrib/utilities/cms_plugins.py b/djangocms_frontend/contrib/utilities/cms_plugins.py index f945c704..c2283154 100644 --- a/djangocms_frontend/contrib/utilities/cms_plugins.py +++ b/djangocms_frontend/contrib/utilities/cms_plugins.py @@ -4,8 +4,7 @@ from djangocms_frontend import settings from ...cms_plugins import CMSUIPlugin -from ...common.attributes import AttributesMixin -from ...common.spacing import SpacingMixin +from ...common import AttributesMixin, SpacingMixin from .. import utilities from . import forms, models @@ -78,6 +77,8 @@ class HeadingPlugin(mixin_factory("Heading"), AttributesMixin, SpacingMixin, CMS ), ] + frontend_editable_fields = ("heading",) + def render(self, context, instance, placeholder): if not hasattr(context["request"], "TOC"): context["request"].TOC = [] @@ -127,6 +128,7 @@ class TOCPlugin(mixin_factory("TOC"), AttributesMixin, CMSUIPlugin): change_form_template = "djangocms_frontend/admin/no_form.html" fieldsets = settings.EMPTY_FIELDSET + edit_disabled = True def render(self, context, instance, placeholder): if hasattr(context["request"], "TOC"): diff --git a/djangocms_frontend/contrib/utilities/forms.py b/djangocms_frontend/contrib/utilities/forms.py index 93c6b0cc..549cb2b4 100644 --- a/djangocms_frontend/contrib/utilities/forms.py +++ b/djangocms_frontend/contrib/utilities/forms.py @@ -4,7 +4,7 @@ from entangled.forms import EntangledModelForm from ... import settings -from ...common.spacing import SpacingFormMixin +from ...common import SpacingFormMixin from ...fields import ( AttributesFormField, ButtonGroup, @@ -91,7 +91,7 @@ class Meta: "attributes", ], } - untangled_fields = ("attributes",) + untangled_fields = [] HEADINGS = ( ("h1", _("Heading 1")), diff --git a/djangocms_frontend/contrib/utilities/templates/djangocms_frontend/heading.html b/djangocms_frontend/contrib/utilities/templates/djangocms_frontend/heading.html index 50c11c5c..66d4cdb8 100644 --- a/djangocms_frontend/contrib/utilities/templates/djangocms_frontend/heading.html +++ b/djangocms_frontend/contrib/utilities/templates/djangocms_frontend/heading.html @@ -1,5 +1,5 @@ -{% load cms_tags %}{% spaceless %} - <{{ instance.heading_level }}{% if instance.heading_id %} id="{{ instance.heading_id }}"{% endif %}{{ instance.get_attributes }}>{{ instance.heading|safe }} +{% load cms_tags frontend %}{% spaceless %} + <{{ instance.heading_level }}{% if instance.heading_id %} id="{{ instance.heading_id }}"{% endif %}{{ instance.get_attributes }}>{% inline_field instance "heading" %} {% if instance.child_plugin_instances %} {% if request.toolbar.edit_mode_active %}
{% endif %} {% for plugin in instance.child_plugin_instances %} diff --git a/djangocms_frontend/frameworks/tailwind.py b/djangocms_frontend/frameworks/tailwind.py new file mode 100644 index 00000000..d0b34526 --- /dev/null +++ b/djangocms_frontend/frameworks/tailwind.py @@ -0,0 +1,95 @@ +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +DEVICE_CHOICES = ( + ("xs", _("Extra small")), # default <576px + ("sm", _("Small")), # default ≥576px + ("md", _("Medium")), # default ≥768px + ("lg", _("Large")), # default ≥992px + ("xl", _("Extra large")), # default ≥1200px + ("xxl", _("Extra-extra large")), # default ≥1200px +) +DEVICE_SIZES = tuple(size for size, name in DEVICE_CHOICES) + +COLOR_STYLE_CHOICES = getattr( + settings, + "DJANGOCMS_FRONTEND_COLOR_STYLE_CHOICES", + ( + ("default", _("Default")), + ("alternative", _("Alternative")), + ("green", _("Green")), + ("red", _("Red")), + ("yellow", _("Yellow")), + ("purple", _("Purple")), + ("light", _("Light")), + ("dark", _("Dark")), + ), +) + +SPACER_PROPERTY_CHOICES = ( + ("m", "margin"), + ("p", "padding"), +) + +SPACER_SIDE_CHOICES = ( + ("", "*"), + ("t", "*-top"), + ("r", "*-right"), + ("b", "*-bottom"), + ("l", "*-left"), + ("x", "*-left & *-right"), + ("y", "*-top & *-bottom"), +) + +SPACER_X_SIDES_CHOICES = ( + ("x", _("Both")), + ("s", _("Left")), + ("e", _("Right")), +) + +SPACER_Y_SIDES_CHOICES = ( + ("y", _("Both")), + ("t", _("Top")), + ("b", _("Bottom")), +) + +SPACER_SIZE_CHOICES = getattr( + settings, + "DJANGOCMS_FRONTEND_SPACER_SIZES", + ( + ("0", "* 0"), + ("1", "* .25"), + ("2", "* .5"), + ("3", "* 1"), + ("4", "* 1.5"), + ("5", "* 3"), + ), +) + +SIZE_X_CHOICES = getattr( + settings, + "DJANGOCMS_FRONTEND_SIZE_X_CHOICES", + ( + ("25", "25%"), + ("50", "50%"), + ("75", "75%"), + ("100", "100%"), + ("auto", _("Auto")), + ("vw-100", _("Screen")), + ), +) + +SIZE_Y_CHOICES = getattr( + settings, + "DJANGOCMS_FRONTEND_SIZE_Y_CHOICES", + ( + ("25", "25%"), + ("50", "50%"), + ("75", "75%"), + ("100", "100%"), + ("auto", _("Auto")), + ("min-vh-100", _("Screen (minimum)")), + ), +) + +NAVBAR_DESIGNS = () diff --git a/djangocms_frontend/helpers.py b/djangocms_frontend/helpers.py index a5fc300e..dd007707 100644 --- a/djangocms_frontend/helpers.py +++ b/djangocms_frontend/helpers.py @@ -1,10 +1,16 @@ import copy import decimal +from cms.constants import SLUG_REGEXP +from cms.plugin_base import CMSPluginBase +from cms.utils.conf import get_cms_setting from django.apps import apps +from django.contrib.admin.helpers import AdminForm from django.db.models import ObjectDoesNotExist +from django.shortcuts import render from django.template.exceptions import TemplateDoesNotExist from django.template.loader import select_template +from django.urls import re_path from django.utils.functional import lazy from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -25,6 +31,18 @@ def get_related_object(scope, field_name): return relobj +class get_related: + """Descriptor lazily getting related objects from the config dict.""" + def __init__(self, key): + self.key = key + + def __get__(self, instance, owner): + obj = get_related_object(instance.config, self.key) + if obj is not None: + setattr(instance, self.key, obj) + return obj + + def insert_fields(fieldsets, new_fields, block=None, position=-1, blockname=None, blockattrs=None): """ creates a copy of fieldsets inserting the new fields either in the indexed block at the position, @@ -139,3 +157,104 @@ def coerce_decimal(value): return decimal.Decimal(value) except TypeError: return None + + +class FrontendEditableAdminMixin: + """ + Adding ``FrontendEditableAdminMixin`` to models admin class allows to open that admin + in the frontend by double-clicking on fields rendered with the ``render_model`` template + tag. + """ + frontend_editable_fields = [] + + def get_urls(self): # pragma: no cover + """ + Register the url for the single field edit view + """ + info = f"{self.model._meta.app_label}_{self.model._meta.model_name}" + + def pat(regex, fn): + return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}") + + url_patterns = [ + pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field), + ] + return url_patterns + super().get_urls() + + def _get_object_for_single_field(self, object_id, language): # pragma: no cover + # Quick and dirty way to retrieve objects for django-hvad + # Cleaner implementation will extend this method in a child mixin + try: + # First see if the model uses the admin manager pattern from cms.models.manager.ContentAdminManager + manager = self.model.admin_manager + except AttributeError: + # If not, use the default manager + manager = self.model.objects + try: + return manager.language(language).get(pk=object_id) + except AttributeError: + return manager.get(pk=object_id) + + def edit_field(self, request, object_id, language): + obj = self._get_object_for_single_field(object_id, language) + opts = obj.__class__._meta + saved_successfully = False + cancel_clicked = request.POST.get("_cancel", False) + raw_fields = request.GET.get("edit_fields") + fields = [field for field in raw_fields.split(",") if field in self.frontend_editable_fields] + if not fields: + context = { + 'opts': opts, + 'message': _("Field %s not found") % raw_fields + } + return render(request, 'admin/cms/page/plugin/error_form.html', context) + if not request.user.has_perm(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}"): + context = { + 'opts': opts, + 'message': _("You do not have permission to edit this item") + } + return render(request, 'admin/cms/page/plugin/error_form.html', context) + # Dynamically creates the form class with only `field_name` field + # enabled + form_class = self.get_form(request, obj, fields=fields) + if not cancel_clicked and request.method == 'POST': + form = form_class(instance=obj, data=request.POST) + if form.is_valid(): + new_object = form.save(commit=False) + self.save_model(request, new_object, form, change=True) + saved_successfully = True + else: + form = form_class(instance=obj) + admin_form = AdminForm(form, fieldsets=[(None, {'fields': fields})], prepopulated_fields={}, + model_admin=self) + media = self.media + admin_form.media + context = { + 'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'), + 'title': opts.verbose_name, + 'plugin': None, + 'plugin_id': None, + 'adminform': admin_form, + 'add': False, + 'is_popup': True, + 'media': media, + 'opts': opts, + 'change': True, + 'save_as': False, + 'has_add_permission': False, + 'window_close_timeout': 10, + } + if cancel_clicked: + # cancel button was clicked + context.update({ + 'cancel': True, + }) + return render(request, 'admin/cms/page/plugin/confirm_form.html', context) + if not cancel_clicked and request.method == 'POST' and saved_successfully: + if isinstance(self, CMSPluginBase): + if hasattr(obj.placeholder, 'mark_as_dirty'): + # Only relevant for v3: mark the placeholder as dirty so user can publish changes + obj.placeholder.mark_as_dirty(obj.language, clear_cache=False) + # Update the structure board by populating the data bridge + return self.render_close_frame(request, obj) + render(request, 'admin/cms/page/plugin/confirm_form.html', context) + return render(request, 'admin/cms/page/plugin/change_form.html', context) diff --git a/djangocms_frontend/models.py b/djangocms_frontend/models.py index d7eff722..64e05afd 100644 --- a/djangocms_frontend/models.py +++ b/djangocms_frontend/models.py @@ -1,5 +1,6 @@ +import uuid + from cms.models import CMSPlugin -from cms.utils.compat import DJANGO_3_0 from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.utils.html import conditional_escape, mark_safe @@ -9,11 +10,6 @@ from djangocms_frontend.fields import TagTypeField from djangocms_frontend.settings import FRAMEWORK_PLUGIN_INFO -if DJANGO_3_0: - from django_jsonfield_backport.models import JSONField -else: - JSONField = models.JSONField - class AbstractFrontendUIItem(CMSPlugin): """ @@ -48,10 +44,11 @@ class Meta: ui_item = models.CharField(max_length=30) tag_type = TagTypeField(blank=True) - config = JSONField(default=dict, encoder=DjangoJSONEncoder) + config = models.JSONField(default=dict, encoder=DjangoJSONEncoder) def __init__(self, *args, **kwargs): self._additional_classes = [] + self.uuid = str(uuid.uuid4()) super().__init__(*args, **kwargs) def __getattr__(self, item): @@ -79,12 +76,15 @@ def add_attribute(self, attr, value=None): def get_attributes(self): attributes = self.config.get("attributes", {}) - classes = set(attributes.get("class", "").split()) - classes.update(self._additional_classes) - if classes: - attributes["class"] = " ".join(classes) - parts = (f'{item}="{conditional_escape(value)}"' if value else f"{item}" for item, value in attributes.items()) - attributes_string = " ".join(parts) + classes = set(attributes.get("class", "").split()) # classes added in attriutes + classes.update(self._additional_classes) # add additional classes + classes = (f'class="{conditional_escape(" ".join(classes))}"') if classes else "" # to string + parts = ( + f'{item}="{conditional_escape(value)}"' if value else f"{item}" + for item, value in attributes.items() + if item != "class" + ) + attributes_string = (classes + " ".join(parts)).strip() return mark_safe(" " + attributes_string) if attributes_string else "" def save(self, *args, **kwargs): @@ -96,6 +96,8 @@ def initialize_from_form(self, form=None): if form is None: form = self.get_plugin_class().form if isinstance(form, type): # if is class + if not getattr(form._meta, "model", None): + form._meta.model = self.__class__ form = form() # instantiate entangled_fields = getattr(getattr(form, "Meta", None), "entangled_fields", {}).get("config", ()) for field in entangled_fields: @@ -103,8 +105,8 @@ def initialize_from_form(self, form=None): return self def get_short_description(self): - """Plugin-specific short description (to be defined by subclasses)""" - return "" + """Plugin-specific short description (to be defined by subclasses). Try title attribute first.""" + return self.config.get("title", "") @property def framework_info(self): diff --git a/djangocms_frontend/settings.py b/djangocms_frontend/settings.py index 07ee1176..e994d74a 100644 --- a/djangocms_frontend/settings.py +++ b/djangocms_frontend/settings.py @@ -72,6 +72,8 @@ ], ) +SHOW_EMPTY_CHILDREN = getattr(django_settings, "DJANGOCMS_FRONTEND_SHOW_EMPTY_CHILDREN", False) + FORM_OPTIONS = getattr(django_settings, "DJANGOCMS_FRONTEND_FORM_OPTIONS", {}) @@ -83,7 +85,7 @@ DEVICE_SIZES = framework_settings.DEVICE_SIZES DEVICE_CHOICES = framework_settings.DEVICE_CHOICES COLOR_STYLE_CHOICES = framework_settings.COLOR_STYLE_CHOICES -COLOR_CODES = framework_settings.COLOR_CODES +# COLOR_CODES = framework_settings.COLOR_CODES FORM_TEMPLATE = getattr(framework_settings, "FORM_TEMPLATE", None) SPACER_PROPERTY_CHOICES = framework_settings.SPACER_PROPERTY_CHOICES SPACER_SIDE_CHOICES = framework_settings.SPACER_SIDE_CHOICES diff --git a/djangocms_frontend/static/djangocms_frontend/css/base.css b/djangocms_frontend/static/djangocms_frontend/css/base.css index 3495fdb2..d73825f1 100644 --- a/djangocms_frontend/static/djangocms_frontend/css/base.css +++ b/djangocms_frontend/static/djangocms_frontend/css/base.css @@ -5,4 +5,4 @@ /private/sass instead */ -@charset "UTF-8";:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:0.8125rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-size:0.8125rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#212529;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-ms-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.015625rem;--bs-btn-border-radius:0.5rem}.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.7109375rem;--bs-btn-border-radius:0.25rem}.link-primary{color:#0d6efd!important}.link-primary:focus,.link-primary:hover{color:#0a58ca!important}.link-secondary{color:#6c757d!important}.link-secondary:focus,.link-secondary:hover{color:#565e64!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#212529!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.3rem;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#6c757d;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:#dee2e6;--bs-nav-tabs-border-radius:0.375rem;--bs-nav-tabs-link-hover-border-color:#e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:#dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:4px;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0bf}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.icon{display:inline-block;vertical-align:top;width:1em;height:1em;background-position:center;background-repeat:no-repeat}.icon svg{display:block;width:100%;height:100%}.icon-info{width:.9em;font-size:110%!important}.icon-white{color:#fff}.icon-white svg{fill:#fff}.icon-black{color:#000}.icon-black svg{fill:#000}.icon-primary{color:#0bf}.icon-primary svg{fill:#0bf}.djangocms-icon .icon>input{float:left;position:relative;top:12px}.djangocms-icon .caret{margin-inline-start:8px}.aligned .frontend-button-group label{min-width:unset}.frontend-button-group .btn{box-sizing:border-box;cursor:pointer;-webkit-appearance:none;margin:2px;overflow:hidden;text-overflow:ellipsis}.frontend-button-group .btn.active{outline:3px solid #0bf;border-color:#fff!important}.frontend-button-group .btn-default.active{border-radius:0;background-color:#0bf!important}.frontend-button-group-context-colors>div,.frontend-button-group-context-size>div{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;min-height:75px}.frontend-button-group-context-colors .btn{-ms-flex-preferred-size:calc(25% - 4px);flex-basis:calc(25% - 4px)}@media (min-width:820px){.frontend-button-group-context-colors .btn{-ms-flex-preferred-size:calc(20% - 4px);flex-basis:calc(20% - 4px)}}.frontend-button-group-icons .icon,.frontend-grid-icons .icon{font-size:24px}.frontend-button-group-icons .icon-flex-align-center,.frontend-button-group-icons .icon-flex-align-end,.frontend-button-group-icons .icon-flex-align-start,.frontend-grid-icons .icon-flex-align-center,.frontend-grid-icons .icon-flex-align-end,.frontend-grid-icons .icon-flex-align-start{transform:scale(1.4)}.frontend-button-group-icons .icon-flex-content-around,.frontend-button-group-icons .icon-flex-content-between,.frontend-grid-icons .icon-flex-content-around,.frontend-grid-icons .icon-flex-content-between{transform:scale(1.6)}.frontend-button-group-icons .icon-flex-self-center,.frontend-button-group-icons .icon-flex-self-end,.frontend-button-group-icons .icon-flex-self-start,.frontend-grid-icons .icon-flex-self-center,.frontend-grid-icons .icon-flex-self-end,.frontend-grid-icons .icon-flex-self-start{transform:scale(1.4)}.frontend-button-group-icons .icon-align-reset,.frontend-button-group-icons .icon-no-selection,.frontend-grid-icons .icon-align-reset,.frontend-grid-icons .icon-no-selection{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-align-items-start,.frontend-button-group-icons .icon-flex-align-start,.frontend-grid-icons .icon-align-items-start,.frontend-grid-icons .icon-flex-align-start{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-items-center,.frontend-button-group-icons .icon-flex-align-center,.frontend-grid-icons .icon-align-items-center,.frontend-grid-icons .icon-flex-align-center{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-items-end,.frontend-button-group-icons .icon-flex-align-end,.frontend-grid-icons .icon-align-items-end,.frontend-grid-icons .icon-flex-align-end{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-flex-content-start,.frontend-button-group-icons .icon-justify-content-start,.frontend-button-group-icons .icon-start,.frontend-grid-icons .icon-flex-content-start,.frontend-grid-icons .icon-justify-content-start,.frontend-grid-icons .icon-start{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-center,.frontend-button-group-icons .icon-flex-content-center,.frontend-button-group-icons .icon-justify-content-center,.frontend-grid-icons .icon-center,.frontend-grid-icons .icon-flex-content-center,.frontend-grid-icons .icon-justify-content-center{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-end,.frontend-button-group-icons .icon-flex-content-end,.frontend-button-group-icons .icon-justify-content-end,.frontend-grid-icons .icon-end,.frontend-grid-icons .icon-flex-content-end,.frontend-grid-icons .icon-justify-content-end{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-flex-content-around,.frontend-button-group-icons .icon-justify-content-around,.frontend-grid-icons .icon-flex-content-around,.frontend-grid-icons .icon-justify-content-around{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.6)}.frontend-button-group-icons .icon-flex-content-between,.frontend-button-group-icons .icon-justify-content-between,.frontend-grid-icons .icon-flex-content-between,.frontend-grid-icons .icon-justify-content-between{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.6)}.frontend-button-group-icons .icon-nav-fill,.frontend-grid-icons .icon-nav-fill{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-nav-justified,.frontend-grid-icons .icon-nav-justified{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-flex-column,.frontend-grid-icons .icon-flex-column{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-self-start,.frontend-button-group-icons .icon-flex-self-start,.frontend-grid-icons .icon-align-self-start,.frontend-grid-icons .icon-flex-self-start{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-self-center,.frontend-button-group-icons .icon-flex-self-center,.frontend-grid-icons .icon-align-self-center,.frontend-grid-icons .icon-flex-self-center{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-self-end,.frontend-button-group-icons .icon-flex-self-end,.frontend-grid-icons .icon-align-self-end,.frontend-grid-icons .icon-flex-self-end{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-size-sm,.frontend-button-group-icons .icon-size-xs,.frontend-button-group-icons .icon-sm,.frontend-button-group-icons .icon-xs,.frontend-grid-icons .icon-size-sm,.frontend-grid-icons .icon-size-xs,.frontend-grid-icons .icon-sm,.frontend-grid-icons .icon-xs{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-size-sm,.frontend-button-group-icons .icon-sm,.frontend-grid-icons .icon-size-sm,.frontend-grid-icons .icon-sm{transform:rotate(-90deg)}.frontend-button-group-icons .icon-md,.frontend-button-group-icons .icon-size-md,.frontend-grid-icons .icon-md,.frontend-grid-icons .icon-size-md{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-lg,.frontend-button-group-icons .icon-size-lg,.frontend-grid-icons .icon-lg,.frontend-grid-icons .icon-size-lg{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-size-xl,.frontend-button-group-icons .icon-xl,.frontend-grid-icons .icon-size-xl,.frontend-grid-icons .icon-xl{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-size-xxl,.frontend-button-group-icons .icon-xxl,.frontend-grid-icons .icon-size-xxl,.frontend-grid-icons .icon-xxl{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-mb,.frontend-grid-icons .icon-mb{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-me,.frontend-grid-icons .icon-me{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-ms,.frontend-grid-icons .icon-ms{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-mt,.frontend-grid-icons .icon-mt{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-mx,.frontend-grid-icons .icon-mx{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-my,.frontend-grid-icons .icon-my{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-pb,.frontend-grid-icons .icon-pb{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-pe,.frontend-grid-icons .icon-pe{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-ps,.frontend-grid-icons .icon-ps{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-pt,.frontend-grid-icons .icon-pt{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-px,.frontend-grid-icons .icon-px{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-py,.frontend-grid-icons .icon-py{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.icon-info{background-image:url('data:image/svg+xml;utf8,')}.module{margin:0 0 20px}.djangocms-frontend-row .form-row.field-create .icon{position:absolute;font-size:30px;margin-block-start:28px;margin-inline-start:4px}.djangocms-frontend-row .form-row.field-create input[name=create]{width:130px!important;padding-inline-end:5px!important;text-align:start}.djangocms-frontend-column .form-row.field-xs_col,.djangocms-frontend-column .form-row.field-xs_me,.djangocms-frontend-column .form-row.field-xs_ms,.djangocms-frontend-column .form-row.field-xs_offset,.djangocms-frontend-column .form-row.field-xs_order,.djangocms-frontend-row .form-row.field-row_cols_xs{position:relative;display:-ms-flexbox;display:flex;padding:0;min-width:800px}.djangocms-frontend-column .form-row.field-xs_col>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_me>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_ms>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_offset>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_order>div>div:not([class]),.djangocms-frontend-row .form-row.field-row_cols_xs>div>div:not([class]){width:unset!important;max-width:unset!important}.djangocms-frontend-column .form-row.field-xs_col .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_me .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_ms .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_offset .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_order .field-box:first-child,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box:first-child{width:115px!important}.djangocms-frontend-column .form-row.field-xs_col .field-box,.djangocms-frontend-column .form-row.field-xs_col .fieldBox,.djangocms-frontend-column .form-row.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_me .fieldBox,.djangocms-frontend-column .form-row.field-xs_ms .field-box,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox,.djangocms-frontend-column .form-row.field-xs_offset .field-box,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox,.djangocms-frontend-column .form-row.field-xs_order .field-box,.djangocms-frontend-column .form-row.field-xs_order .fieldBox,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox{position:relative;box-sizing:content-box;width:86px!important;-ms-flex:none;flex:none;display:block;padding:15px 10px;margin:0!important;border-bottom:1px solid #eee;float:left!important}.djangocms-frontend-column .form-row.field-xs_col .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_col .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_me .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_me .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_ms .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_ms .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_offset .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_offset .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_order .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_order .fieldBox input:not([type=checkbox]),.djangocms-frontend-row .form-row.field-row_cols_xs .field-box input:not([type=checkbox]),.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox input:not([type=checkbox]){text-align:end;padding-inline-end:5px!important;box-sizing:border-box;width:100%}.djangocms-frontend-column .form-row.field-xs_col .field-box label,.djangocms-frontend-column .form-row.field-xs_col .fieldBox label,.djangocms-frontend-column .form-row.field-xs_me .field-box label,.djangocms-frontend-column .form-row.field-xs_me .fieldBox label,.djangocms-frontend-column .form-row.field-xs_ms .field-box label,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox label,.djangocms-frontend-column .form-row.field-xs_offset .field-box label,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox label,.djangocms-frontend-column .form-row.field-xs_order .field-box label,.djangocms-frontend-column .form-row.field-xs_order .fieldBox label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box label,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox label{font-size:12px!important;font-weight:400!important;color:#ccc!important;position:absolute;inset-inline-start:15px;inset-block-end:17px;text-transform:lowercase}.djangocms-frontend-column .form-row.field-xs_col .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_col .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_me .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_me .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_ms .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_offset .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_order .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_order .fieldBox .disabled,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box .disabled,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox .disabled{color:#ccc;background:#eee}.djangocms-frontend-column .form-row.field-xs_col .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_col .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_me .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_me .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_ms .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_offset .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_order .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_order .fieldBox:last-child,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box:last-child,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox:last-child{border-inline-end:none}.djangocms-frontend-column .form-row.field-xs_col .errors,.djangocms-frontend-column .form-row.field-xs_me .errors,.djangocms-frontend-column .form-row.field-xs_ms .errors,.djangocms-frontend-column .form-row.field-xs_offset .errors,.djangocms-frontend-column .form-row.field-xs_order .errors,.djangocms-frontend-row .form-row.field-row_cols_xs .errors{margin-bottom:0}.djangocms-frontend-column .form-row.field-xs_col .errorlist,.djangocms-frontend-column .form-row.field-xs_me .errorlist,.djangocms-frontend-column .form-row.field-xs_ms .errorlist,.djangocms-frontend-column .form-row.field-xs_offset .errorlist,.djangocms-frontend-column .form-row.field-xs_order .errorlist,.djangocms-frontend-row .form-row.field-row_cols_xs .errorlist{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.djangocms-frontend-column .form-row.field-xs_col.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_me.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_ms.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_offset.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_order.field-xs_me .field-box,.djangocms-frontend-row .form-row.field-row_cols_xs.field-xs_me .field-box{border-bottom:none}.djangocms-frontend-column .form-row.field-xs_col .field-box-label,.djangocms-frontend-column .form-row.field-xs_me .field-box-label,.djangocms-frontend-column .form-row.field-xs_ms .field-box-label,.djangocms-frontend-column .form-row.field-xs_offset .field-box-label,.djangocms-frontend-column .form-row.field-xs_order .field-box-label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box-label{display:-ms-flexbox;display:flex;margin-top:auto;color:#999}.djangocms-frontend-column .form-row.field-xs_col .field-box-label a,.djangocms-frontend-column .form-row.field-xs_me .field-box-label a,.djangocms-frontend-column .form-row.field-xs_ms .field-box-label a,.djangocms-frontend-column .form-row.field-xs_offset .field-box-label a,.djangocms-frontend-column .form-row.field-xs_order .field-box-label a,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box-label a{width:100%;margin-top:auto;color:#999}.djangocms-frontend-column .form-row.field-xs_col .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_me .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_ms .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_offset .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_order .field-box-label a a,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box-label a a{width:100%;margin-top:auto}.djangocms-frontend-column .form-row.field-xs_col .field-lg_me,.djangocms-frontend-column .form-row.field-xs_col .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_col .field-md_me,.djangocms-frontend-column .form-row.field-xs_col .field-md_ms,.djangocms-frontend-column .form-row.field-xs_col .field-sm_me,.djangocms-frontend-column .form-row.field-xs_col .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_col .field-xl_me,.djangocms-frontend-column .form-row.field-xs_col .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_col .field-xs_me,.djangocms-frontend-column .form-row.field-xs_col .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_me .field-lg_me,.djangocms-frontend-column .form-row.field-xs_me .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_me .field-md_me,.djangocms-frontend-column .form-row.field-xs_me .field-md_ms,.djangocms-frontend-column .form-row.field-xs_me .field-sm_me,.djangocms-frontend-column .form-row.field-xs_me .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_me .field-xl_me,.djangocms-frontend-column .form-row.field-xs_me .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_me .field-xs_me,.djangocms-frontend-column .form-row.field-xs_me .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_me,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-md_me,.djangocms-frontend-column .form-row.field-xs_ms .field-md_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_me,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_me,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_me,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_me,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-md_me,.djangocms-frontend-column .form-row.field-xs_offset .field-md_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_me,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_me,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_me,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_order .field-lg_me,.djangocms-frontend-column .form-row.field-xs_order .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_order .field-md_me,.djangocms-frontend-column .form-row.field-xs_order .field-md_ms,.djangocms-frontend-column .form-row.field-xs_order .field-sm_me,.djangocms-frontend-column .form-row.field-xs_order .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_order .field-xl_me,.djangocms-frontend-column .form-row.field-xs_order .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_order .field-xs_me,.djangocms-frontend-column .form-row.field-xs_order .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_ms{text-align:start}.djangocms-frontend-column .form-row.field-xs_col .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_col .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-md_me label,.djangocms-frontend-column .form-row.field-xs_col .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_col .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_col .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_col .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_me .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-md_me label,.djangocms-frontend-column .form-row.field-xs_me .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_me .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_me .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_me .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-md_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-md_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_order .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-md_me label,.djangocms-frontend-column .form-row.field-xs_order .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_order .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_order .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_order .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_ms label{inset-inline-start:30px;inset-block-start:14px}.djangocms-frontend-column .form-row.field-xs_col .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_col .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-md_me input,.djangocms-frontend-column .form-row.field-xs_col .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_col .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_col .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_col .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_me .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-md_me input,.djangocms-frontend-column .form-row.field-xs_me .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_me .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_me .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_me .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-md_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-md_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_order .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-md_me input,.djangocms-frontend-column .form-row.field-xs_order .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_order .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_order .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_order .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_ms input{position:relative;box-sizing:border-box;top:-3px}.grid-reset{position:absolute;inset-inline-end:5px;inset-block-start:0}.icon-thead{text-align:center;margin-bottom:15px}.icon-thead .icon{font-size:30px}.icon-thead .icon-size-sm{transform:rotate(90deg)}.icon-title{display:block;font-size:12px;color:#999;padding:5px 0 0}.djangocms-frontend-preview{position:fixed;inset-block-start:0;inset-inline-end:0;z-index:10;text-align:center;border-radius:0 0 0 3px;padding:10px 20px 27px;border:1px solid var(--dca-gray,var(--hairline-color,#ccc));border-block-start:none;border-inline-end:none;background:var(--body-bg,#fff)}@media (prefers-color-scheme:dark){.djangocms-frontend-preview{background:var(--body-bg,#000)}}.djangocms-frontend-preview h2{font-size:14px;min-width:150px;margin:0 0 12px}.djangocms-frontend-preview .b4-preview{margin:0 0 -15px}.djangocms-frontend-preview .b4-close{position:absolute;inset-inline-end:10px;inset-block-start:8px;z-index:100;display:block;color:#5e5e5e;font-size:12px;line-height:20px;font-weight:700;text-transform:uppercase;width:20px;height:20px;border-radius:3px;background:#ddd}.djangocms-frontend-preview .b4-close:hover{color:#fff!important;text-decoration:none;background:#0bf}.djangocms-frontend-preview .btn>span{vertical-align:middle}.djangocms-frontend-preview .btn>span>.icon{vertical-align:middle}.djangocms-frontend-preview .btn>span svg,.djangocms-frontend-preview .btn>span use{fill:currentColor}.djangocms-frontend-blockquote textarea{height:110px}#id_link_type{padding:0;margin:0;border:none}#id_link_type li{padding:0;margin:0 15px 5px 0;border:none}#id_link_type label input{position:relative;top:-4px}a[data-pk]{position:relative}a[data-pk]:after{content:attr(data-pk);visibility:hidden;width:auto;font-weight:400;font-size:80%;background-color:var(--dca-white,var(--body-bg,#fff));color:var(--dca-gray,var(--body-fg,#333));border:solid 1px var(--dca-gray,var(--body-fg,#333));text-align:center;padding:5px 10px;position:absolute;z-index:1;top:110%;inset-inline-start:50%;margin-inline-start:-50%}a[data-pk]:hover:after{visibility:visible}.djangocms-admin-style .form-row.field-plugin_title input[name=plugin_title_0]{margin-bottom:.5em!important}.djangocms-admin-style .form-row.field-plugin_title input[name=plugin_title_1]{width:calc(100% - 2em)!important}body:not(.djangocms-admin-style) .form-row.field-plugin_title input[name=plugin_title_1]{width:calc(100% - 200px - 1em)!important;margin-inline-start:1em}.frontend-icon-picker{text-align:center;display:inline-block}.frontend-icon-picker .icon-container{position:relative;margin:.5em auto;width:7em;height:7em;border:1px var(--dca-gray-light,var(--border-color,#d3d3d3)) solid;transition:background-color .15s,color .15s}.frontend-icon-picker .icon-container .icon-preview{width:7em;height:7em;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center}.frontend-icon-picker .icon-container .icon-preview .icon-box{font-size:500%;line-height:1.3;margin:0;text-align:center}.frontend-icon-picker .icon-container .icon-preview .icon-box i,.frontend-icon-picker .icon-container .icon-preview .icon-box span{font-size:unset}.frontend-icon-picker .icon-container .icon-preview .empty-box{text-align:center;overflow:hidden;text-overflow:ellipsis;line-height:1;font-size:100%}.frontend-icon-picker .icon-container .icon-preview .empty-box.hidden{display:none}.frontend-icon-picker .icon-container .icon-preview:hover{background:var(--dca-gray-light,var(--border-color,#d3d3d3));cursor:pointer}.frontend-icon-picker .icon-container .icon-close-indicator{display:block;border-radius:50%;color:var(--dca-black,var(--body-fg,#000));background-color:var(--dca-white,var(--body-bg,#fff));padding:.3rem;border:1px solid var(--dca-black,var(--body-fg,#000));transform:translate(-50%,-50%);top:0;inset-inline-start:100%;width:.6em;height:.6em;line-height:.5em;position:absolute;transition:background-color .15s}.frontend-icon-picker .icon-container .icon-close-indicator:before{content:"×"}.frontend-icon-picker .icon-container .icon-close-indicator:hover{background:var(--delete-button-bg,red);color:var(--delete-button-fg,#fff);cursor:pointer}.uip-modal{position:fixed;height:100%;width:100%;inset-block-end:0;inset-inline-start:0;background-color:rgba(0,0,0,.8);z-index:9999;-webkit-user-select:none;-ms-user-select:none;user-select:none;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.uip-modal *,.uip-modal :after,.uip-modal :before{box-sizing:border-box}.uip-modal.uip-close{opacity:0;visibility:hidden;transition:all .4s ease-in-out}.uip-modal.uip-open{opacity:1;visibility:visible;transition:all .4s ease-in-out}.uip-modal .uip-modal--content{position:absolute;border-radius:3px;box-shadow:2px 8px 23px 3px rgba(0,0,0,.2);overflow:hidden;font-family:Roboto,Arial,Helvetica,Verdana,sans-serif;background-color:var(--dca-gray-lightest,var(--darkened-bg,#f8f8f8));width:100%;margin:auto;left:0;right:0;margin-bottom:2em}.uip-modal .uip-modal--content .uip-modal--header{padding:15px 15px;background-color:var(--dca-white,var(--bg-color,#fff));box-shadow:0 0 8px rgba(0,0,0,.1);position:relative;z-index:1;font-size:15px;color:var(--dca-gray,var(--body-quiet-color,#666));font-weight:500;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.uip-modal .uip-modal--content .uip-modal--header .uip-modal--header-logo-title{padding-top:2px;line-height:1;text-transform:uppercase;font-weight:700;cursor:pointer}.uip-modal .uip-modal--content .uip-modal--header .uip-modal--header-close-btn{cursor:pointer}.uip-modal .uip-modal--content .uip-modal--body{font-size:12px;line-height:1.5;box-sizing:border-box;padding:0;height:70vh;display:-ms-flexbox;display:flex;min-height:50px;max-height:85vh;overflow:auto}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar{-ms-flex-negative:0;flex-shrink:0;max-width:25%}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs{margin-top:30px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item{padding:15px;font-size:14px;color:var(--dca-gray,var(--body-quiet-color,#666));text-align:start;cursor:pointer;position:relative;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;text-transform:capitalize}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item i{font-size:20px;padding-inline-end:15px;color:var(--dca-gray-lighter,var(--border-color,#ccc))}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item img{padding-inline-end:15px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item.universal-active{background-color:var(--dca-white,var(--bg-color,#fff));box-shadow:0 6px 20px 0 rgba(0,0,0,.1)}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item.universal-active:after{content:"";position:absolute;height:100%;width:5px;inset-block-start:0;inset-inline-start:0;background-color:#0bf}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item.universal-active i{color:#0bf}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item:only-child{display:none}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding:30px 80px 0;width:100%}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner{overflow:auto;margin:25px -15px 0;padding:0 15px 15px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview{display:-ms-grid;display:grid;grid-gap:20px;margin:20px 0}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item{position:relative;padding:10px;background-color:var(--dca-white,var(--bg-color,#fff));box-shadow:0 1px 12px rgba(0,0,0,.05);border-radius:3px;cursor:pointer;transition:all .3s;overflow:hidden}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item:hover{box-shadow:0 1px 14px rgba(0,0,0,.16)}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item.universal-selected{box-shadow:0 1px 12px rgba(0,0,0,.05),0 0 0 3px #0bf}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;padding:1px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner .uip-icon-item__icon,.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner i{font-size:25px;color:var(--dca-gray-darkest,var(--body-fg,#333))}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner .uip-icon-item-name{color:var(--dca-gray,var(--body-quiet-color,#666));font-size:11px;padding:13px 0 0;max-width:100%;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;text-transform:capitalize}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search{position:relative}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search input{width:100%;padding:8px 15px;background-color:var(--dca-white,var(--bg-color,#fff));border:none}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search input:-ms-input-placeholder{font-style:italic}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search input::placeholder{font-style:italic}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search img{position:absolute;top:50%;transform:translateY(-50%);inset-inline-end:10px}.uip-modal .uip-modal--footer{border-top:1px solid var(--dca-gray-lighter,var(--border-color,#ccc));text-align:center;background-color:var(--dca-white,var(--bg-color,#fff));border:none;display:none;-ms-flex-pack:end;justify-content:flex-end;padding:5px;box-shadow:0 0 8px rgba(0,0,0,.1);position:relative;display:-ms-flexbox;display:flex}.uip-modal .uip-modal--footer button.uip-insert-icon-button{padding:10px 35px!important;color:var(--dca-white,var(--bg-color,#fff))!important;background-color:#0bf!important;border:none;cursor:pointer;outline:0}.uip-modal .uip-modal--footer .universal-button{height:40px;margin-inline-start:5px}.uip-modal .uip-modal--footer .universal-button-success{padding:12px 36px;color:var(--dca-white,var(--bg-color,#fff));width:initial}.uip-modal .uip-modal--footer .universal-button-success:hover{background-color:#0bf}@media (min-width:1440px){body:not(.cms-admin-modal) .uip-modal .uip-modal--content{max-width:1200px}}@media (max-width:1439px){body:not(.cms-admin-modal) .uip-modal .uip-modal--content{max-width:990px}.uip-modal--icon-preview-wrap{padding:30px 50px 0}}@media (max-width:1023px){body:not(.cms-admin-modal) .uip-modal .uip-modal--content{max-width:740px}}@media (max-width:767px){.uip-modal--icon-preview-wrap{padding:15px!important}.uip-modal--sidebar{display:none}}@media (min-width:1440px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[7];grid-template-columns:repeat(7,1fr)}}@media (max-width:1439px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[6];grid-template-columns:repeat(6,1fr)}}@media (max-width:1024px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[5];grid-template-columns:repeat(5,1fr)}}@media (max-width:767px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[4];grid-template-columns:repeat(4,1fr)}}@media (max-width:479px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[3];grid-template-columns:repeat(3,1fr)}}@media (max-width:1439px){.uip-modal--sidebar-tab-item{padding:15px 15px 15px 25px;font-size:11px}.uip-modal--sidebar-tab-item i{font-size:15px}}@media (max-width:1024px){.uip-modal--sidebar-tab-item i,.uip-modal--sidebar-tab-item img{display:none}}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}ul.nav{margin-bottom:1em}ul.nav>li.nav-item{list-style-type:none;padding:inherit}.colM ul:not(.object-tools).nav{margin-top:0;margin-bottom:20px}ul.nav .nav-item{margin-inline-end:1rem}ul.nav .nav-link{position:relative;text-decoration:none}ul.nav .nav-link span.indicator{display:none;border-radius:50%;padding:.5rem;border:1px solid var(--dca-white,var(--body-bg,#fff));transform:translate(-50%,-50%);inset-block-start:0;inset-inline-start:100%;position:absolute}ul.nav .nav-link span.indicator.error{background-color:var(--bs-danger)}ul.nav .nav-link span.indicator.attributes{background-color:var(--bs-info);display:block}ul.nav .nav-link.error>span.indicator{display:block}ul.nav.nav-pills .nav-link:not(.active){border-style:solid;border-width:1px}body:not(.djangocms-admin-style) ul.djangocms-frontend.nav-tabs+div.tab-content .tab-pane{border-left-style:solid;border-bottom-style:solid;border-right-style:solid;border-left-color:var(--hairline-color);border-bottom-color:var(--hairline-color);border-right-color:var(--hairline-color);border-width:1px}body:not(.djangocms-admin-style) ul.djangocms-frontend.nav-tabs+div.tab-content .tab-pane fieldset:last-child{margin-bottom:0}div.tab-pk{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center;color:var(--dca-gray-darker,var(--body-fg,#333));font-size:80%;margin-inline-start:auto}.djangocms-admin-style .colM ul.nav:not(.object-tools):not(.messagelist){margin-top:0}.djangocms-admin-style .colM ul.nav:not(.object-tools):not(.messagelist) li.nav-item{border-top:none}input[type=number].auto-field+span{display:none;position:absolute;inset-block-end:0;inset-inline-end:0;text-align:end;margin-inline-end:31px;margin-block-end:23px;cursor:pointer}body:not(.djangocms-admin-style) input[type=number].auto-field+span{margin-bottom:23px}@media (max-width:1024px){body:not(.djangocms-admin-style) input[type=number].auto-field+span{margin-bottom:24px}}input[type=number].auto-field+span:after{content:"auto"}input[type=number].auto-field.auto{color:var(--dca-white,var(--body-bg,#fff));caret-color:var(--dca-black,var(--body-fg,#000))}input[type=number].auto-field.auto+span{display:block} \ No newline at end of file +@charset "UTF-8";:root{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-body-color-rgb:33,37,41;--bs-body-bg-rgb:255,255,255;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:0.8125rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-bg:#fff;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-2xl:2rem;--bs-border-radius-pill:50rem;--bs-link-color:#0d6efd;--bs-link-hover-color:#0a58ca;--bs-code-color:#d63384;--bs-highlight-bg:#fff3cd}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-size:0.8125rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:#212529;--bs-btn-bg:transparent;--bs-btn-border-width:1px;--bs-btn-border-color:transparent;--bs-btn-border-radius:0.375rem;--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-ms-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:none;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.015625rem;--bs-btn-border-radius:0.5rem}.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.7109375rem;--bs-btn-border-radius:0.25rem}.link-primary{color:#0d6efd!important}.link-primary:focus,.link-primary:hover{color:#0a58ca!important}.link-secondary{color:#6c757d!important}.link-secondary:focus,.link-secondary:hover{color:#565e64!important}.link-success{color:#198754!important}.link-success:focus,.link-success:hover{color:#146c43!important}.link-info{color:#0dcaf0!important}.link-info:focus,.link-info:hover{color:#3dd5f3!important}.link-warning{color:#ffc107!important}.link-warning:focus,.link-warning:hover{color:#ffcd39!important}.link-danger{color:#dc3545!important}.link-danger:focus,.link-danger:hover{color:#b02a37!important}.link-light{color:#f8f9fa!important}.link-light:focus,.link-light:hover{color:#f9fafb!important}.link-dark{color:#212529!important}.link-dark:focus,.link-dark:hover{color:#1a1e21!important}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.3rem;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:#6c757d;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link.disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:1px;--bs-nav-tabs-border-color:#dee2e6;--bs-nav-tabs-border-radius:0.375rem;--bs-nav-tabs-link-hover-border-color:#e9ecef #e9ecef #dee2e6;--bs-nav-tabs-link-active-color:#495057;--bs-nav-tabs-link-active-bg:#fff;--bs-nav-tabs-link-active-border-color:#dee2e6 #dee2e6 #fff;border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));background:0 0;border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-link.disabled,.nav-tabs .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:4px;--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0bf}.nav-pills .nav-link{background:0 0;border:0;border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link:disabled{color:var(--bs-nav-link-disabled-color);background-color:transparent;border-color:transparent}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.icon{display:inline-block;vertical-align:top;width:1em;height:1em;background-position:center;background-repeat:no-repeat}.icon svg{display:block;width:100%;height:100%}.icon-info{width:.9em;font-size:110%!important}.icon-white{color:#fff}.icon-white svg{fill:#fff}.icon-black{color:#000}.icon-black svg{fill:#000}.icon-primary{color:#0bf}.icon-primary svg{fill:#0bf}.djangocms-icon .icon>input{float:left;position:relative;top:12px}.djangocms-icon .caret{margin-inline-start:8px}.aligned .frontend-button-group label{min-width:unset}.frontend-button-group{display:inline-block}.frontend-button-group .btn{box-sizing:border-box;cursor:pointer;-webkit-appearance:none;margin:2px;overflow:hidden;text-overflow:ellipsis}.frontend-button-group .btn.active{outline:3px solid #0bf;border-color:#fff!important}.frontend-button-group .btn-default.active{border-radius:0;background-color:#0bf!important}.frontend-button-group-context-colors>div,.frontend-button-group-context-size>div{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;min-height:75px}.frontend-button-group-context-colors .btn{-ms-flex-preferred-size:calc(25% - 4px);flex-basis:calc(25% - 4px)}@media (min-width:820px){.frontend-button-group-context-colors .btn{-ms-flex-preferred-size:calc(20% - 4px);flex-basis:calc(20% - 4px)}}.frontend-button-group-icons .icon,.frontend-grid-icons .icon{font-size:24px}.frontend-button-group-icons .icon-flex-align-center,.frontend-button-group-icons .icon-flex-align-end,.frontend-button-group-icons .icon-flex-align-start,.frontend-grid-icons .icon-flex-align-center,.frontend-grid-icons .icon-flex-align-end,.frontend-grid-icons .icon-flex-align-start{transform:scale(1.4)}.frontend-button-group-icons .icon-flex-content-around,.frontend-button-group-icons .icon-flex-content-between,.frontend-grid-icons .icon-flex-content-around,.frontend-grid-icons .icon-flex-content-between{transform:scale(1.6)}.frontend-button-group-icons .icon-flex-self-center,.frontend-button-group-icons .icon-flex-self-end,.frontend-button-group-icons .icon-flex-self-start,.frontend-grid-icons .icon-flex-self-center,.frontend-grid-icons .icon-flex-self-end,.frontend-grid-icons .icon-flex-self-start{transform:scale(1.4)}.frontend-button-group-icons .icon-align-reset,.frontend-button-group-icons .icon-no-selection,.frontend-grid-icons .icon-align-reset,.frontend-grid-icons .icon-no-selection{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-align-items-start,.frontend-button-group-icons .icon-flex-align-start,.frontend-grid-icons .icon-align-items-start,.frontend-grid-icons .icon-flex-align-start{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-items-center,.frontend-button-group-icons .icon-flex-align-center,.frontend-grid-icons .icon-align-items-center,.frontend-grid-icons .icon-flex-align-center{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-items-end,.frontend-button-group-icons .icon-flex-align-end,.frontend-grid-icons .icon-align-items-end,.frontend-grid-icons .icon-flex-align-end{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-flex-content-start,.frontend-button-group-icons .icon-justify-content-start,.frontend-button-group-icons .icon-start,.frontend-grid-icons .icon-flex-content-start,.frontend-grid-icons .icon-justify-content-start,.frontend-grid-icons .icon-start{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-center,.frontend-button-group-icons .icon-flex-content-center,.frontend-button-group-icons .icon-justify-content-center,.frontend-grid-icons .icon-center,.frontend-grid-icons .icon-flex-content-center,.frontend-grid-icons .icon-justify-content-center{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-end,.frontend-button-group-icons .icon-flex-content-end,.frontend-button-group-icons .icon-justify-content-end,.frontend-grid-icons .icon-end,.frontend-grid-icons .icon-flex-content-end,.frontend-grid-icons .icon-justify-content-end{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-flex-content-around,.frontend-button-group-icons .icon-justify-content-around,.frontend-grid-icons .icon-flex-content-around,.frontend-grid-icons .icon-justify-content-around{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.6)}.frontend-button-group-icons .icon-flex-content-between,.frontend-button-group-icons .icon-justify-content-between,.frontend-grid-icons .icon-flex-content-between,.frontend-grid-icons .icon-justify-content-between{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.6)}.frontend-button-group-icons .icon-nav-fill,.frontend-grid-icons .icon-nav-fill{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-nav-justified,.frontend-grid-icons .icon-nav-justified{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-flex-column,.frontend-grid-icons .icon-flex-column{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-self-start,.frontend-button-group-icons .icon-flex-self-start,.frontend-grid-icons .icon-align-self-start,.frontend-grid-icons .icon-flex-self-start{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-self-center,.frontend-button-group-icons .icon-flex-self-center,.frontend-grid-icons .icon-align-self-center,.frontend-grid-icons .icon-flex-self-center{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-align-self-end,.frontend-button-group-icons .icon-flex-self-end,.frontend-grid-icons .icon-align-self-end,.frontend-grid-icons .icon-flex-self-end{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16));transform:scale(1.4)}.frontend-button-group-icons .icon-size-sm,.frontend-button-group-icons .icon-size-xs,.frontend-button-group-icons .icon-sm,.frontend-button-group-icons .icon-xs,.frontend-grid-icons .icon-size-sm,.frontend-grid-icons .icon-size-xs,.frontend-grid-icons .icon-sm,.frontend-grid-icons .icon-xs{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-size-sm,.frontend-button-group-icons .icon-sm,.frontend-grid-icons .icon-size-sm,.frontend-grid-icons .icon-sm{transform:rotate(-90deg)}.frontend-button-group-icons .icon-md,.frontend-button-group-icons .icon-size-md,.frontend-grid-icons .icon-md,.frontend-grid-icons .icon-size-md{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-lg,.frontend-button-group-icons .icon-size-lg,.frontend-grid-icons .icon-lg,.frontend-grid-icons .icon-size-lg{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-size-xl,.frontend-button-group-icons .icon-xl,.frontend-grid-icons .icon-size-xl,.frontend-grid-icons .icon-xl{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-size-xxl,.frontend-button-group-icons .icon-xxl,.frontend-grid-icons .icon-size-xxl,.frontend-grid-icons .icon-xxl{background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 16))}.frontend-button-group-icons .icon-mb,.frontend-grid-icons .icon-mb{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-me,.frontend-grid-icons .icon-me{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-ms,.frontend-grid-icons .icon-ms{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-mt,.frontend-grid-icons .icon-mt{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-mx,.frontend-grid-icons .icon-mx{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-my,.frontend-grid-icons .icon-my{transform:scale(1.3);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-pb,.frontend-grid-icons .icon-pb{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-pe,.frontend-grid-icons .icon-pe{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-ps,.frontend-grid-icons .icon-ps{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-pt,.frontend-grid-icons .icon-pt{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-px,.frontend-grid-icons .icon-px{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.frontend-button-group-icons .icon-py,.frontend-grid-icons .icon-py{transform:scale(1.5);background-image:url('data:image/svg+xml;utf8,');filter:brightness(calc(var(--dca-light-mode, 1) + var(--dca-dark-mode, 0) * 100))}.icon-info{background-image:url('data:image/svg+xml;utf8,')}.module{margin:0 0 20px}.djangocms-frontend-row .form-row.field-create .icon{position:absolute;font-size:30px;margin-block-start:28px;margin-inline-start:4px}.djangocms-frontend-row .form-row.field-create input[name=create]{width:130px!important;padding-inline-end:5px!important;text-align:start}.djangocms-frontend-column .form-row.field-xs_col,.djangocms-frontend-column .form-row.field-xs_me,.djangocms-frontend-column .form-row.field-xs_ms,.djangocms-frontend-column .form-row.field-xs_offset,.djangocms-frontend-column .form-row.field-xs_order,.djangocms-frontend-row .form-row.field-row_cols_xs{position:relative;display:-ms-flexbox;display:flex;padding:0;min-width:800px}.djangocms-frontend-column .form-row.field-xs_col>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_me>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_ms>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_offset>div>div:not([class]),.djangocms-frontend-column .form-row.field-xs_order>div>div:not([class]),.djangocms-frontend-row .form-row.field-row_cols_xs>div>div:not([class]){width:unset!important;max-width:unset!important}.djangocms-frontend-column .form-row.field-xs_col .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_me .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_ms .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_offset .field-box:first-child,.djangocms-frontend-column .form-row.field-xs_order .field-box:first-child,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box:first-child{width:115px!important}.djangocms-frontend-column .form-row.field-xs_col .field-box,.djangocms-frontend-column .form-row.field-xs_col .fieldBox,.djangocms-frontend-column .form-row.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_me .fieldBox,.djangocms-frontend-column .form-row.field-xs_ms .field-box,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox,.djangocms-frontend-column .form-row.field-xs_offset .field-box,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox,.djangocms-frontend-column .form-row.field-xs_order .field-box,.djangocms-frontend-column .form-row.field-xs_order .fieldBox,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox{position:relative;box-sizing:content-box;width:86px!important;-ms-flex:none;flex:none;display:block;padding:15px 10px;margin:0!important;border-bottom:1px solid #eee;float:left!important}.djangocms-frontend-column .form-row.field-xs_col .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_col .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_me .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_me .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_ms .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_ms .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_offset .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_offset .fieldBox input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_order .field-box input:not([type=checkbox]),.djangocms-frontend-column .form-row.field-xs_order .fieldBox input:not([type=checkbox]),.djangocms-frontend-row .form-row.field-row_cols_xs .field-box input:not([type=checkbox]),.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox input:not([type=checkbox]){text-align:end;padding-inline-end:5px!important;box-sizing:border-box;width:100%}.djangocms-frontend-column .form-row.field-xs_col .field-box label,.djangocms-frontend-column .form-row.field-xs_col .fieldBox label,.djangocms-frontend-column .form-row.field-xs_me .field-box label,.djangocms-frontend-column .form-row.field-xs_me .fieldBox label,.djangocms-frontend-column .form-row.field-xs_ms .field-box label,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox label,.djangocms-frontend-column .form-row.field-xs_offset .field-box label,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox label,.djangocms-frontend-column .form-row.field-xs_order .field-box label,.djangocms-frontend-column .form-row.field-xs_order .fieldBox label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box label,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox label{font-size:12px!important;font-weight:400!important;color:#ccc!important;position:absolute;inset-inline-start:15px;inset-block-end:17px;text-transform:lowercase}.djangocms-frontend-column .form-row.field-xs_col .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_col .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_me .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_me .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_ms .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_offset .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox .disabled,.djangocms-frontend-column .form-row.field-xs_order .field-box .disabled,.djangocms-frontend-column .form-row.field-xs_order .fieldBox .disabled,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box .disabled,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox .disabled{color:#ccc;background:#eee}.djangocms-frontend-column .form-row.field-xs_col .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_col .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_me .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_me .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_ms .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_ms .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_offset .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_offset .fieldBox:last-child,.djangocms-frontend-column .form-row.field-xs_order .field-box:last-child,.djangocms-frontend-column .form-row.field-xs_order .fieldBox:last-child,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box:last-child,.djangocms-frontend-row .form-row.field-row_cols_xs .fieldBox:last-child{border-inline-end:none}.djangocms-frontend-column .form-row.field-xs_col .errors,.djangocms-frontend-column .form-row.field-xs_me .errors,.djangocms-frontend-column .form-row.field-xs_ms .errors,.djangocms-frontend-column .form-row.field-xs_offset .errors,.djangocms-frontend-column .form-row.field-xs_order .errors,.djangocms-frontend-row .form-row.field-row_cols_xs .errors{margin-bottom:0}.djangocms-frontend-column .form-row.field-xs_col .errorlist,.djangocms-frontend-column .form-row.field-xs_me .errorlist,.djangocms-frontend-column .form-row.field-xs_ms .errorlist,.djangocms-frontend-column .form-row.field-xs_offset .errorlist,.djangocms-frontend-column .form-row.field-xs_order .errorlist,.djangocms-frontend-row .form-row.field-row_cols_xs .errorlist{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.djangocms-frontend-column .form-row.field-xs_col.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_me.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_ms.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_offset.field-xs_me .field-box,.djangocms-frontend-column .form-row.field-xs_order.field-xs_me .field-box,.djangocms-frontend-row .form-row.field-row_cols_xs.field-xs_me .field-box{border-bottom:none}.djangocms-frontend-column .form-row.field-xs_col .field-box-label,.djangocms-frontend-column .form-row.field-xs_me .field-box-label,.djangocms-frontend-column .form-row.field-xs_ms .field-box-label,.djangocms-frontend-column .form-row.field-xs_offset .field-box-label,.djangocms-frontend-column .form-row.field-xs_order .field-box-label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box-label{display:-ms-flexbox;display:flex;margin-top:auto;color:#999}.djangocms-frontend-column .form-row.field-xs_col .field-box-label a,.djangocms-frontend-column .form-row.field-xs_me .field-box-label a,.djangocms-frontend-column .form-row.field-xs_ms .field-box-label a,.djangocms-frontend-column .form-row.field-xs_offset .field-box-label a,.djangocms-frontend-column .form-row.field-xs_order .field-box-label a,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box-label a{width:100%;margin-top:auto;color:#999}.djangocms-frontend-column .form-row.field-xs_col .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_me .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_ms .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_offset .field-box-label a a,.djangocms-frontend-column .form-row.field-xs_order .field-box-label a a,.djangocms-frontend-row .form-row.field-row_cols_xs .field-box-label a a{width:100%;margin-top:auto}.djangocms-frontend-column .form-row.field-xs_col .field-lg_me,.djangocms-frontend-column .form-row.field-xs_col .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_col .field-md_me,.djangocms-frontend-column .form-row.field-xs_col .field-md_ms,.djangocms-frontend-column .form-row.field-xs_col .field-sm_me,.djangocms-frontend-column .form-row.field-xs_col .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_col .field-xl_me,.djangocms-frontend-column .form-row.field-xs_col .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_col .field-xs_me,.djangocms-frontend-column .form-row.field-xs_col .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_me .field-lg_me,.djangocms-frontend-column .form-row.field-xs_me .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_me .field-md_me,.djangocms-frontend-column .form-row.field-xs_me .field-md_ms,.djangocms-frontend-column .form-row.field-xs_me .field-sm_me,.djangocms-frontend-column .form-row.field-xs_me .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_me .field-xl_me,.djangocms-frontend-column .form-row.field-xs_me .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_me .field-xs_me,.djangocms-frontend-column .form-row.field-xs_me .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_me,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-md_me,.djangocms-frontend-column .form-row.field-xs_ms .field-md_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_me,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_me,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_me,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_me,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-md_me,.djangocms-frontend-column .form-row.field-xs_offset .field-md_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_me,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_me,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_me,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_ms,.djangocms-frontend-column .form-row.field-xs_order .field-lg_me,.djangocms-frontend-column .form-row.field-xs_order .field-lg_ms,.djangocms-frontend-column .form-row.field-xs_order .field-md_me,.djangocms-frontend-column .form-row.field-xs_order .field-md_ms,.djangocms-frontend-column .form-row.field-xs_order .field-sm_me,.djangocms-frontend-column .form-row.field-xs_order .field-sm_ms,.djangocms-frontend-column .form-row.field-xs_order .field-xl_me,.djangocms-frontend-column .form-row.field-xs_order .field-xl_ms,.djangocms-frontend-column .form-row.field-xs_order .field-xs_me,.djangocms-frontend-column .form-row.field-xs_order .field-xs_ms,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_me,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_ms,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_me,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_ms{text-align:start}.djangocms-frontend-column .form-row.field-xs_col .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_col .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-md_me label,.djangocms-frontend-column .form-row.field-xs_col .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_col .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_col .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_col .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_me .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-md_me label,.djangocms-frontend-column .form-row.field-xs_me .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_me .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_me .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_me .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-md_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-md_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-lg_me label,.djangocms-frontend-column .form-row.field-xs_order .field-lg_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-md_me label,.djangocms-frontend-column .form-row.field-xs_order .field-md_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-sm_me label,.djangocms-frontend-column .form-row.field-xs_order .field-sm_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-xl_me label,.djangocms-frontend-column .form-row.field-xs_order .field-xl_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-xs_me label,.djangocms-frontend-column .form-row.field-xs_order .field-xs_ms label,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_me label,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_ms label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_me label,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_ms label{inset-inline-start:30px;inset-block-start:14px}.djangocms-frontend-column .form-row.field-xs_col .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_col .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-md_me input,.djangocms-frontend-column .form-row.field-xs_col .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_col .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_col .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_col .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_col .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_me .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-md_me input,.djangocms-frontend-column .form-row.field-xs_me .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_me .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_me .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_me .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_me .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-md_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_ms .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-md_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_offset .field-xxl_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-lg_me input,.djangocms-frontend-column .form-row.field-xs_order .field-lg_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-md_me input,.djangocms-frontend-column .form-row.field-xs_order .field-md_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-sm_me input,.djangocms-frontend-column .form-row.field-xs_order .field-sm_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-xl_me input,.djangocms-frontend-column .form-row.field-xs_order .field-xl_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-xs_me input,.djangocms-frontend-column .form-row.field-xs_order .field-xs_ms input,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_me input,.djangocms-frontend-column .form-row.field-xs_order .field-xxl_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-lg_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-md_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-sm_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xl_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xs_ms input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_me input,.djangocms-frontend-row .form-row.field-row_cols_xs .field-xxl_ms input{position:relative;box-sizing:border-box;top:-3px}.grid-reset{position:absolute;inset-inline-end:5px;inset-block-start:0}.icon-thead{text-align:center;margin-bottom:15px}.icon-thead .icon{font-size:30px}.icon-thead .icon-size-sm{transform:rotate(90deg)}.icon-title{display:block;font-size:12px;color:#999;padding:5px 0 0}.djangocms-frontend-preview{position:fixed;inset-block-start:0;inset-inline-end:0;z-index:10;text-align:center;border-radius:0 0 0 3px;padding:10px 20px 27px;border:1px solid var(--dca-gray,var(--hairline-color,#ccc));border-block-start:none;border-inline-end:none;background:var(--body-bg,#fff)}@media (prefers-color-scheme:dark){.djangocms-frontend-preview{background:var(--body-bg,#000)}}.djangocms-frontend-preview h2{font-size:14px;min-width:150px;margin:0 0 12px}.djangocms-frontend-preview .b4-preview{margin:0 0 -15px}.djangocms-frontend-preview .b4-close{position:absolute;inset-inline-end:10px;inset-block-start:8px;z-index:100;display:block;color:#5e5e5e;font-size:12px;line-height:20px;font-weight:700;text-transform:uppercase;width:20px;height:20px;border-radius:3px;background:#ddd}.djangocms-frontend-preview .b4-close:hover{color:#fff!important;text-decoration:none;background:#0bf}.djangocms-frontend-preview .btn>span{vertical-align:middle}.djangocms-frontend-preview .btn>span>.icon{vertical-align:middle}.djangocms-frontend-preview .btn>span svg,.djangocms-frontend-preview .btn>span use{fill:currentColor}.djangocms-frontend-blockquote textarea{height:110px}#id_link_type{padding:0;margin:0;border:none}#id_link_type li{padding:0;margin:0 15px 5px 0;border:none}#id_link_type label input{position:relative;top:-4px}a[data-pk]{position:relative}a[data-pk]:after{content:attr(data-pk);visibility:hidden;width:auto;font-weight:400;font-size:80%;background-color:var(--dca-white,var(--body-bg,#fff));color:var(--dca-gray,var(--body-fg,#333));border:solid 1px var(--dca-gray,var(--body-fg,#333));text-align:center;padding:5px 10px;position:absolute;z-index:1;top:110%;inset-inline-start:50%;margin-inline-start:-50%}a[data-pk]:hover:after{visibility:visible}.djangocms-admin-style .form-row.field-plugin_title input[name=plugin_title_0]{margin-bottom:.5em!important}.djangocms-admin-style .form-row.field-plugin_title input[name=plugin_title_1]{width:calc(100% - 2em)!important}body:not(.djangocms-admin-style) .form-row.field-plugin_title input[name=plugin_title_1]{width:calc(100% - 200px - 1em)!important;margin-inline-start:1em}.frontend-icon-picker{text-align:center;display:inline-block}.frontend-icon-picker .icon-container{position:relative;margin:.5em auto;width:7em;height:7em;border:1px var(--dca-gray-light,var(--border-color,#d3d3d3)) solid;transition:background-color .15s,color .15s}.frontend-icon-picker .icon-container .icon-preview{width:7em;height:7em;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center}.frontend-icon-picker .icon-container .icon-preview .icon-box{font-size:500%;line-height:1.3;margin:0;text-align:center}.frontend-icon-picker .icon-container .icon-preview .icon-box i,.frontend-icon-picker .icon-container .icon-preview .icon-box span{font-size:unset}.frontend-icon-picker .icon-container .icon-preview .empty-box{text-align:center;overflow:hidden;text-overflow:ellipsis;line-height:1;font-size:100%}.frontend-icon-picker .icon-container .icon-preview .empty-box.hidden{display:none}.frontend-icon-picker .icon-container .icon-preview:hover{background:var(--dca-gray-light,var(--border-color,#d3d3d3));cursor:pointer}.frontend-icon-picker .icon-container .icon-close-indicator{display:block;border-radius:50%;color:var(--dca-black,var(--body-fg,#000));background-color:var(--dca-white,var(--body-bg,#fff));padding:.3rem;border:1px solid var(--dca-black,var(--body-fg,#000));transform:translate(-50%,-50%);top:0;inset-inline-start:100%;width:.6em;height:.6em;line-height:.5em;position:absolute;transition:background-color .15s}.frontend-icon-picker .icon-container .icon-close-indicator:before{content:"×"}.frontend-icon-picker .icon-container .icon-close-indicator:hover{background:var(--delete-button-bg,red);color:var(--delete-button-fg,#fff);cursor:pointer}.uip-modal{position:fixed;height:100%;width:100%;inset-block-end:0;inset-inline-start:0;background-color:rgba(0,0,0,.8);z-index:9999;-webkit-user-select:none;-ms-user-select:none;user-select:none;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.uip-modal *,.uip-modal :after,.uip-modal :before{box-sizing:border-box}.uip-modal.uip-close{opacity:0;visibility:hidden;transition:all .4s ease-in-out}.uip-modal.uip-open{opacity:1;visibility:visible;transition:all .4s ease-in-out}.uip-modal .uip-modal--content{position:absolute;border-radius:3px;box-shadow:2px 8px 23px 3px rgba(0,0,0,.2);overflow:hidden;font-family:Roboto,Arial,Helvetica,Verdana,sans-serif;background-color:var(--dca-gray-lightest,var(--darkened-bg,#f8f8f8));width:100%;margin:auto;left:0;right:0;margin-bottom:2em}.uip-modal .uip-modal--content .uip-modal--header{padding:15px 15px;background-color:var(--dca-white,var(--bg-color,#fff));box-shadow:0 0 8px rgba(0,0,0,.1);position:relative;z-index:1;font-size:15px;color:var(--dca-gray,var(--body-quiet-color,#666));font-weight:500;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.uip-modal .uip-modal--content .uip-modal--header .uip-modal--header-logo-title{padding-top:2px;line-height:1;text-transform:uppercase;font-weight:700;cursor:pointer}.uip-modal .uip-modal--content .uip-modal--header .uip-modal--header-close-btn{cursor:pointer}.uip-modal .uip-modal--content .uip-modal--body{font-size:12px;line-height:1.5;box-sizing:border-box;padding:0;height:70vh;display:-ms-flexbox;display:flex;min-height:50px;max-height:85vh;overflow:auto}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar{-ms-flex-negative:0;flex-shrink:0;max-width:25%}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs{margin-top:30px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item{padding:15px;font-size:14px;color:var(--dca-gray,var(--body-quiet-color,#666));text-align:start;cursor:pointer;position:relative;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;text-transform:capitalize}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item i{font-size:20px;padding-inline-end:15px;color:var(--dca-gray-lighter,var(--border-color,#ccc))}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item img{padding-inline-end:15px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item.universal-active{background-color:var(--dca-white,var(--bg-color,#fff));box-shadow:0 6px 20px 0 rgba(0,0,0,.1)}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item.universal-active:after{content:"";position:absolute;height:100%;width:5px;inset-block-start:0;inset-inline-start:0;background-color:#0bf}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item.universal-active i{color:#0bf}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--sidebar .uip-modal--sidebar-tabs .uip-modal--sidebar-tab-item:only-child{display:none}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding:30px 80px 0;width:100%}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner{overflow:auto;margin:25px -15px 0;padding:0 15px 15px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview{display:-ms-grid;display:grid;grid-gap:20px;margin:20px 0}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item{position:relative;padding:10px;background-color:var(--dca-white,var(--bg-color,#fff));box-shadow:0 1px 12px rgba(0,0,0,.05);border-radius:3px;cursor:pointer;transition:all .3s;overflow:hidden}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item:hover{box-shadow:0 1px 14px rgba(0,0,0,.16)}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item.universal-selected{box-shadow:0 1px 12px rgba(0,0,0,.05),0 0 0 3px #0bf}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-align:center;align-items:center;padding:1px}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner .uip-icon-item__icon,.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner i{font-size:25px;color:var(--dca-gray-darkest,var(--body-fg,#333))}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-preview-inner .uip-modal--icon-preview .uip-icon-item .uip-icon-item-inner .uip-icon-item-name{color:var(--dca-gray,var(--body-quiet-color,#666));font-size:11px;padding:13px 0 0;max-width:100%;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;text-transform:capitalize}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search{position:relative}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search input{width:100%;padding:8px 15px;background-color:var(--dca-white,var(--bg-color,#fff));border:none}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search input:-ms-input-placeholder{font-style:italic}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search input::placeholder{font-style:italic}.uip-modal .uip-modal--content .uip-modal--body .uip-modal--icon-preview-wrap .uip-modal--icon-search img{position:absolute;top:50%;transform:translateY(-50%);inset-inline-end:10px}.uip-modal .uip-modal--footer{border-top:1px solid var(--dca-gray-lighter,var(--border-color,#ccc));text-align:center;background-color:var(--dca-white,var(--bg-color,#fff));border:none;display:none;-ms-flex-pack:end;justify-content:flex-end;padding:5px;box-shadow:0 0 8px rgba(0,0,0,.1);position:relative;display:-ms-flexbox;display:flex}.uip-modal .uip-modal--footer button.uip-insert-icon-button{padding:10px 35px!important;color:var(--dca-white,var(--bg-color,#fff))!important;background-color:#0bf!important;border:none;cursor:pointer;outline:0}.uip-modal .uip-modal--footer .universal-button{height:40px;margin-inline-start:5px}.uip-modal .uip-modal--footer .universal-button-success{padding:12px 36px;color:var(--dca-white,var(--bg-color,#fff));width:initial}.uip-modal .uip-modal--footer .universal-button-success:hover{background-color:#0bf}@media (min-width:1440px){body:not(.cms-admin-modal) .uip-modal .uip-modal--content{max-width:1200px}}@media (max-width:1439px){body:not(.cms-admin-modal) .uip-modal .uip-modal--content{max-width:990px}.uip-modal--icon-preview-wrap{padding:30px 50px 0}}@media (max-width:1023px){body:not(.cms-admin-modal) .uip-modal .uip-modal--content{max-width:740px}}@media (max-width:767px){.uip-modal--icon-preview-wrap{padding:15px!important}.uip-modal--sidebar{display:none}}@media (min-width:1440px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[7];grid-template-columns:repeat(7,1fr)}}@media (max-width:1439px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[6];grid-template-columns:repeat(6,1fr)}}@media (max-width:1024px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[5];grid-template-columns:repeat(5,1fr)}}@media (max-width:767px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[4];grid-template-columns:repeat(4,1fr)}}@media (max-width:479px){.uip-modal--icon-preview{-ms-grid-columns:(1fr)[3];grid-template-columns:repeat(3,1fr)}}@media (max-width:1439px){.uip-modal--sidebar-tab-item{padding:15px 15px 15px 25px;font-size:11px}.uip-modal--sidebar-tab-item i{font-size:15px}}@media (max-width:1024px){.uip-modal--sidebar-tab-item i,.uip-modal--sidebar-tab-item img{display:none}}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}ul.nav{margin-bottom:1em}ul.nav>li.nav-item{list-style-type:none;padding:inherit}.colM ul:not(.object-tools).nav{margin-top:0;margin-bottom:20px}ul.nav .nav-item{margin-inline-end:1rem}ul.nav .nav-link{position:relative;text-decoration:none}ul.nav .nav-link span.indicator{display:none;border-radius:50%;padding:.5rem;border:1px solid var(--dca-white,var(--body-bg,#fff));transform:translate(-50%,-50%);inset-block-start:0;inset-inline-start:100%;position:absolute}ul.nav .nav-link span.indicator.error{background-color:var(--bs-danger)}ul.nav .nav-link span.indicator.attributes{background-color:var(--bs-info);display:block}ul.nav .nav-link.error>span.indicator{display:block}ul.nav.nav-pills .nav-link:not(.active){border-style:solid;border-width:1px}body:not(.djangocms-admin-style) ul.djangocms-frontend.nav-tabs+div.tab-content .tab-pane{border-left-style:solid;border-bottom-style:solid;border-right-style:solid;border-left-color:var(--hairline-color);border-bottom-color:var(--hairline-color);border-right-color:var(--hairline-color);border-width:1px}body:not(.djangocms-admin-style) ul.djangocms-frontend.nav-tabs+div.tab-content .tab-pane fieldset:last-child{margin-bottom:0}div.tab-pk{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center;color:var(--dca-gray-darker,var(--body-fg,#333));font-size:80%;margin-inline-start:auto}.djangocms-admin-style .colM ul.nav:not(.object-tools):not(.messagelist){margin-top:0}.djangocms-admin-style .colM ul.nav:not(.object-tools):not(.messagelist) li.nav-item{border-top:none}input[type=number].auto-field+span{display:none;position:absolute;inset-block-end:0;inset-inline-end:0;text-align:end;margin-inline-end:31px;margin-block-end:23px;cursor:pointer}body:not(.djangocms-admin-style) input[type=number].auto-field+span{margin-bottom:23px}@media (max-width:1024px){body:not(.djangocms-admin-style) input[type=number].auto-field+span{margin-bottom:24px}}input[type=number].auto-field+span:after{content:"auto"}input[type=number].auto-field.auto{color:var(--dca-white,var(--body-bg,#fff));caret-color:var(--dca-black,var(--body-fg,#000))}input[type=number].auto-field.auto+span{display:block} \ No newline at end of file diff --git a/djangocms_frontend/static/djangocms_frontend/css/structure_board.css b/djangocms_frontend/static/djangocms_frontend/css/structure_board.css new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_frontend/templates/djangocms_frontend/html_container.html b/djangocms_frontend/templates/djangocms_frontend/html_container.html index 3d53526d..8f1b0d5d 100644 --- a/djangocms_frontend/templates/djangocms_frontend/html_container.html +++ b/djangocms_frontend/templates/djangocms_frontend/html_container.html @@ -1,5 +1,7 @@ -{% load cms_tags %}<{{ instance.tag_type }}{{ instance.get_attributes }}> +{% load cms_tags frontend %}<{{ instance.tag_type }}{{ instance.get_attributes }}> {% for plugin in instance.child_plugin_instances %} {% with parentloop=forloop parent=instance %}{% render_plugin plugin %}{% endwith %} - {% empty %}{{ instance.simple_content }}{% endfor %} + {% empty %} + {% if instance.simple_content %}{{ instance.simple_content }}{% else %}{% user_message _("Add content here") %}{% endif %} + {% endfor %} diff --git a/djangocms_frontend/templates/djangocms_frontend/user_message.html b/djangocms_frontend/templates/djangocms_frontend/user_message.html new file mode 100644 index 00000000..0ae2258c --- /dev/null +++ b/djangocms_frontend/templates/djangocms_frontend/user_message.html @@ -0,0 +1,5 @@ +{% if message %} +
+ {{ message }} +
+{% endif %} diff --git a/djangocms_frontend/templates/tailwind/base.html b/djangocms_frontend/templates/tailwind/base.html new file mode 100644 index 00000000..5372e1d8 --- /dev/null +++ b/djangocms_frontend/templates/tailwind/base.html @@ -0,0 +1,57 @@ +{% extends "djangocms_frontend.html" %}{% load cms_tags menu_tags %} +{% block base_css %} + + +{% endblock %} +{% block base_js %}{% endblock %} +{% block navbar %} + +{% endblock %} diff --git a/djangocms_frontend/templates/tailwind/dropdown.html b/djangocms_frontend/templates/tailwind/dropdown.html new file mode 100644 index 00000000..84a70730 --- /dev/null +++ b/djangocms_frontend/templates/tailwind/dropdown.html @@ -0,0 +1,5 @@ +{% load i18n menu_tags cache %} + {% for child in children %} + {{ child.get_menu_title }} + {% endfor %} +
diff --git a/djangocms_frontend/templates/tailwind/menu.html b/djangocms_frontend/templates/tailwind/menu.html new file mode 100644 index 00000000..46559634 --- /dev/null +++ b/djangocms_frontend/templates/tailwind/menu.html @@ -0,0 +1,26 @@ +{% load i18n menu_tags cache %}{% spaceless %} + {% for child in children %} + {% if child.selected %} + {{ child.get_menu_title }} + {% else %} + {{ child.get_menu_title }} + {% endif %} + {% if child.children %} + + + {% endif %} + {% endfor %} +{% endspaceless %} diff --git a/djangocms_frontend/templatetags/frontend.py b/djangocms_frontend/templatetags/frontend.py index fa4d432a..87172e25 100644 --- a/djangocms_frontend/templatetags/frontend.py +++ b/djangocms_frontend/templatetags/frontend.py @@ -1,12 +1,21 @@ import json +import typing +from classytags.arguments import Argument, MultiKeywordArgument +from classytags.core import Options, Tag +from classytags.helpers import AsTag +from cms.models import CMSPlugin +from cms.templatetags.cms_tags import CMSEditableObject, render_plugin from django import template +from django.conf import settings as django_settings from django.contrib.contenttypes.models import ContentType from django.core.serializers.json import DjangoJSONEncoder +from django.db import models from django.template.defaultfilters import safe from django.utils.encoding import force_str from django.utils.functional import Promise from django.utils.html import conditional_escape, mark_safe +from entangled.forms import EntangledModelFormMixin from djangocms_frontend import settings from djangocms_frontend.fields import HTMLsanitized @@ -95,3 +104,187 @@ def framework_info(context, item, as_json=True): if as_json else framework_info.get(item, "") ) + + +@register.inclusion_tag("djangocms_frontend/user_message.html", takes_context=True) +def user_message(context, message): + """Renders a user message""" + toolbar = getattr(context.get("request", None), "toolbar", None) + if settings.SHOW_EMPTY_CHILDREN and toolbar.edit_mode_active: + return {"message": message} + return {} + + +@register.tag +class SlotTag(Tag): + name = "slot" + options = Options( + Argument("slot_name", required=True), + blocks=[("endslot", "nodelist")], + ) + + def render_tag(self, context, slot_name, nodelist): + return "" + + +class DummyPlugin: + def __init__(self, nodelist, plugin_type, slot_name: typing.Optional[str] = None) -> "DummyPlugin": + self.nodelist = nodelist + self.plugin_type = (f"{plugin_type}{slot_name.capitalize()}Plugin") if slot_name else "DummyPlugin" + if slot_name is None: + self.parse_slots(nodelist, plugin_type) + super().__init__() + + def parse_slots(self, nodelist, plugin_type): + self.slots = [self] + for node in nodelist: + if isinstance(node, SlotTag): + self.slots.append(DummyPlugin(node.nodelist, plugin_type, node.kwargs.get("slot_name"))) + + def get_instances(self): + return self.slots + + +class Plugin(AsTag): + name = "plugin" + options = Options( + Argument("name", required=True), + MultiKeywordArgument("kwargs", required=False), + "as", + Argument("varname", resolve=False, required=False), + blocks=[("endplugin", "nodelist")], + ) + + def message(self, message): + return f"" if django_settings.DEBUG else "" + + def get_value(self, context, name, kwargs, nodelist): + from djangocms_frontend.component_pool import plugin_tag_pool + + if name not in plugin_tag_pool: + return self.message(f'Plugin "{name}" not found in pool for plugins usable with {{% plugin %}}') + context.push() + instance = plugin_tag_pool[name]["defaults"] + plugin_class = plugin_tag_pool[name]["class"] + + if issubclass(plugin_class.form, EntangledModelFormMixin): + # Handle entangled forms such as djangocms-frontend's correctly + for field, value in kwargs.items(): + for container, fields in plugin_class.form._meta.entangled_fields.items(): + if field in fields: + if isinstance(value, models.Model): + # Correctly encode references + value = { + 'model': f'{value._meta.app_label}.{value._meta.model_name}', + 'pk': value.pk, + } + instance[container][field] = value + break + else: + instance[field] = value + else: + instance.update(kwargs) + # Create context + context["instance"] = plugin_class.model(**instance) + # Call render method of plugin + context = plugin_class().render(context, context["instance"], None) + # Replace inner plugins with the nodelist, i.e. the content within the plugin tag + context["instance"].child_plugin_instances = DummyPlugin( + nodelist, context["instance"].plugin_type + ).get_instances() + # ... and redner + result = plugin_tag_pool[name]["template"].render(context.flatten()) + context.pop() + return result + + +class RenderChildPluginsTag(Tag): + """ + This template node is used to render child plugins of a plugin + instance. It allows for selection of certain plugin types. + + e.g.: {% childplugins instance %} + + {% childplugins instance "LinkPlugin" %} + + {% placeholder "footer" inherit or %} + About us + {% endplaceholder %} + + Keyword arguments: + name -- the name of the placeholder + inherit -- optional argument which if given will result in inheriting + the content of the placeholder with the same name on parent pages + or -- optional argument which if given will make the template tag a block + tag whose content is shown if the placeholder is empty + """ + + name = "childplugins" + options = Options( + # PlaceholderOptions parses until the "endchildplugins" tag is found if + # the "or" option is given + Argument("instance", required=True), + Argument("plugin_type", required=False), + blocks=[("endchildplugins", "nodelist")], + ) + + def render_tag(self, context, instance, plugin_type, nodelist): + context.push() + context["parent"] = instance + content = "" + if plugin_type and not plugin_type.endswith("Plugin"): + plugin_type = f"{instance.__class__.__name__}{plugin_type.capitalize()}Plugin" + for child in instance.child_plugin_instances: + if plugin_type is None or child.plugin_type == plugin_type: + if isinstance(child, DummyPlugin): + content += child.nodelist.render(context) + else: + content += render_plugin(context, child) + content = content or getattr(instance, "simple_content", "") + + if not content.strip() and nodelist: + # "or" parameter given + return nodelist.render(context) + + context.pop() + return content + + +class InlineField(CMSEditableObject): + name = 'inline_field' + options = Options( + Argument('instance'), + Argument('attribute'), + Argument('language', default=None, required=False), + Argument('filters', default=None, required=False), + Argument('view_url', default=None, required=False), + Argument('view_method', default=None, required=False), + 'as', + Argument('varname', required=False, resolve=False), + ) + + def render_tag(self, context, **kwargs): + if context["request"].session.get("inline_editing", True) and isinstance(kwargs["instance"], CMSPlugin): + # Only allow inline field to be rendered if inline editing is active and the instance is a CMSPlugin + # DummyPlugins of the ``plugin`` tag are cannot be edited + kwargs["edit_fields"] = kwargs["attribute"] + return super().render_tag(context, **kwargs) + else: + return getattr(kwargs["instance"], kwargs["attribute"], "") + + def _get_editable_context(self, context, instance, language, edit_fields, + view_method, view_url, querystring, editmode=True): + # Fix a not-so-clean solution in django CMS' core: While the template engine checks if an attribute is + # callable, python expects get_plugin_name to be a method. This is a workaround to make it a method. + context = super()._get_editable_context( + context, instance, language, edit_fields, view_method, view_url, querystring, editmode + ) + if hasattr(instance, "get_plugin_name") and isinstance(instance.get_plugin_name, str): + value = str(instance.get_plugin_name) + instance.get_plugin_name = lambda: value + return context + + +register.tag(Plugin.name, Plugin) +register.tag(RenderChildPluginsTag.name, RenderChildPluginsTag) +register.tag(InlineField.name, InlineField) diff --git a/djangocms_frontend/views.py b/djangocms_frontend/views.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/requirements.txt b/docs/requirements.txt index 904eb428..6b256cf9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,96 +4,107 @@ # # pip-compile --output-file=requirements.txt requirements.in # -alabaster==0.7.15 +alabaster==1.0.0 # via sphinx -babel==2.14.0 +anyio==4.6.0 + # via + # starlette + # watchfiles +babel==2.16.0 # via sphinx -beautifulsoup4==4.12.2 +beautifulsoup4==4.12.3 # via furo -build==1.0.3 +build==1.2.2 # via pip-tools -certifi==2023.11.17 +certifi==2024.8.30 # via requests charset-normalizer==3.3.2 # via requests click==8.1.7 - # via pip-tools + # via + # pip-tools + # uvicorn colorama==0.4.6 # via sphinx-autobuild -docutils==0.20.1 +docutils==0.21.2 # via sphinx -furo==2023.9.10 +furo==2024.8.6 # via -r requirements.in -idna==3.6 - # via requests +h11==0.14.0 + # via uvicorn +idna==3.10 + # via + # anyio + # requests imagesize==1.4.1 # via sphinx -jinja2==3.1.2 +jinja2==3.1.4 # via sphinx -livereload==2.6.3 - # via sphinx-autobuild -markupsafe==2.1.3 +markupsafe==2.1.5 # via jinja2 -packaging==23.2 +packaging==24.1 # via # build # sphinx -pip-tools==7.3.0 +pip-tools==7.4.1 # via -r requirements.in pyenchant==3.2.2 # via sphinxcontrib-spelling -pygments==2.17.2 +pygments==2.18.0 # via # furo # sphinx -pyproject-hooks==1.0.0 - # via build -requests==2.31.0 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +requests==2.32.3 # via sphinx -six==1.16.0 - # via livereload +sniffio==1.3.1 + # via anyio snowballstemmer==2.2.0 # via sphinx -soupsieve==2.5 +soupsieve==2.6 # via beautifulsoup4 -sphinx==7.2.6 +sphinx==8.0.2 # via # -r requirements.in # furo # sphinx-autobuild # sphinx-basic-ng # sphinx-copybutton - # sphinxcontrib-applehelp - # sphinxcontrib-devhelp - # sphinxcontrib-htmlhelp - # sphinxcontrib-qthelp - # sphinxcontrib-serializinghtml # sphinxcontrib-spelling -sphinx-autobuild==2021.3.14 +sphinx-autobuild==2024.9.19 # via -r requirements.in sphinx-basic-ng==1.0.0b2 # via furo sphinx-copybutton==0.5.2 # via -r requirements.in -sphinxcontrib-applehelp==1.0.7 +sphinxcontrib-applehelp==2.0.0 # via sphinx -sphinxcontrib-devhelp==1.0.5 +sphinxcontrib-devhelp==2.0.0 # via sphinx -sphinxcontrib-htmlhelp==2.0.4 +sphinxcontrib-htmlhelp==2.1.0 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.6 +sphinxcontrib-qthelp==2.0.0 # via sphinx -sphinxcontrib-serializinghtml==1.1.9 +sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphinxcontrib-spelling==8.0.0 # via -r requirements.in -tornado==6.4 - # via livereload -urllib3==2.1.0 +starlette==0.39.1 + # via sphinx-autobuild +urllib3==2.2.3 # via requests -wheel==0.42.0 +uvicorn==0.30.6 + # via sphinx-autobuild +watchfiles==0.24.0 + # via sphinx-autobuild +websockets==13.1 + # via sphinx-autobuild +wheel==0.44.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/docs/source/components.rst b/docs/source/components.rst index 19acfe13..d3fdf149 100644 --- a/docs/source/components.rst +++ b/docs/source/components.rst @@ -2,19 +2,24 @@ .. index:: single: Plugins -################### - Component plugins -################### +############################ + Standard Component plugins +############################ ``djangocms-frontend`` adds a set of plugins to Django-CMS to allow for quick usage of components defined by the underlying css framework, e.g. -bootstrap 5. +Bootstrap 5. While ``djangocoms-frontend`` is set up to become framework agnostic its heritage from ``djangocms-bootstrap4`` is intentionally and quite visible. Hence for the time being, this documentation references the Bootstrap 5 documentation. +.. note:: + + Custom components can easily be added using the components contrib + package. For more information see :ref:`custom_components`. + .. index:: single: Accordion @@ -35,9 +40,29 @@ each collapsable section. Also see Bootstrap 5 `Accordion `_ documentation. +Re-usable component example +=========================== + +The accordion component is a good example of a re-usable component. It can be +used in all your project's templates. Here is an example of how to create an +accordion (if key word arguments are skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "accordion" accordion_header_type="h2" accordion_flush=False %} + {% plugin "accordionitem" accordion_item_header="Accordion item 1" accordion_item_open=True %} + Content of accordion item 1 + {% endplugin %} + {% plugin "accordionitem" accordion_item_header="Accordion item 2" %} + Content of accordion item 1 + {% endplugin %} + {% endplugin %} + .. index:: single: Alert + *************** Alert component *************** @@ -61,6 +86,21 @@ the right hand side. Also see Bootstrap 5 `Alerts `_ documentation. +Re-usable component example +=========================== + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "alert" alert_context="primary" alert_dismissible=True %} + Alert text goes here! + {% endplugin %} + + .. index:: single: Badge @@ -79,6 +119,22 @@ plugin, badges are useful, e.g., to mark featured or new headers. Also see Bootstrap 5 `Badge `_ documentation. +Re-usable component example +=========================== + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "badge" badge_text="My badge" badge_context="info" badge_pills=False %} + This content is ignored. + {% endplugin %} + + + .. index:: single: Card single: CardInner @@ -149,6 +205,31 @@ Here is an example of the new card **Image overlay** feature: Also see Bootstrap 5 `Card `_ documentation. +Re-usable component example +=========================== + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "card" card_alignment="center" card_outline="info" + card_text_color="primary" card_full_height=True %} + {% plugin "cardinner" inner_type="card-header" text_alignment="start" %} +

Card title

+ {% endplugin %} + {% plugin "cardinner" inner_type="card-body" text_alignment="center" %} + Some quick example text to build on the card title and make up the + bulk of the card's content. + {% endplugin %} + {% plugin "listgroupitem" %}An item{% endplugin %} + {% plugin "listgroupitem" %}A second item{% endplugin %} + {% plugin "listgroupitem" %}A third item{% endplugin %} + {% endplugin %} + + .. index:: single: Carousel @@ -173,6 +254,51 @@ specified using the ``DJANGOCMS_FRONTEND_CAROUSEL_TEMPLATES`` setting. specified the child plugins add to the caption. If no image is specified the child plugins make up the slide. +Re-usable component example +=========================== + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "carousel" template="my_template" carousel_controls=True %} + {% plugin "carouselslide" %} +

Carousel slide title

+

Some more content...

+ {% endplugin %} + {% plugin "carouselslide" %} +

Carousel slide title

+

Some more content...

+ {% endplugin %} + {% endplugin %} + +Parameters for ``{% plugin "carousel" %}`` are: + +* ``template``: The template to use for the carousel. If not specified the + default template is used. +* ``carousel_controls``: If set to ``True`` the carousel will have controls. +* ``carousel_indicators``: If set to ``True`` the carousel will have indicators. +* ``carousel_interval``: The interval in milliseconds between slides. If not + specified the default interval (5000) is used. +* ``carousel_pause``: If set to ``hover`` the carousel will pause on hover. +* ``carousel_wrap``: If set to ``True`` the carousel will wrap around. +* ``carousel_keyboard``: If set to ``True`` the carousel will react to keyboard + events. +* ``carousel_ride``: If set to ``True`` the carousel will start sliding + automatically. +* ``carousel_aspect_ratio``: The aspect ratio of the carousel. If not specified + the default aspect ratio (16:9) is used. + +Parameters for ``{% plugin "carouselslide" %}`` are: + +* ``carousel_image``: The image to display in the slide. If not specified the + slide will be empty. +* ``carousel_content``: The HTML caption to display in the slide. + + ****************** Collapse component ****************** @@ -184,6 +310,7 @@ button) to reveal itself. Compared to the accordion component the collapse component often is more flexible but also requires more detailed styling. + .. index:: single: Jumbotron @@ -239,6 +366,23 @@ For more information, see is registered and the logged in user has view permissions: A user will only see a destination if they can view it in the admin site. +Re-usable component example +=========================== + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% url 'some_view' as some_view %} + {% plugin "textlink" external_link=some_view link_type="btn" link_context="primary" link_outline=False %} + Click me! + {% endplugin %} + + + ******************** List group component ******************** @@ -391,6 +535,46 @@ Tabs component ``DJANGOCMS_FRONTEND_TAB_EFFECTS`` setting. +Re-usable component example +=========================== + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "tab" template="my_template" tab_type="nav-pills" tab_align="justify-content-center" %} + {% plugin "tabitem" tab_title="Tab 1" tab_bordered=True %} +

Content of tab 1

+

Some content...

+ {% endplugin %} + {% plugin "tabitem" tab_title="Tab 2" tab_bordered=True %} +

Content of tab 2

+

Some more content...

+ {% endplugin %} + {% endplugin %} + + +Parameters for ``{% plugin "tab" %}`` are: + +* ``template``: The template to use for the tabs. If not specified the default + template is used. +* ``tab_type``: The type of the tabs. If not specified the default type is used. +* ``tab_align``: The alignment of the tabs. If not specified the default alignment + is used. +* ``tab_index``: The index of the initially active tab. If not specified the + first tab is active. +* ``tab_effect``: The effect of the tabs. ``"fade"`` is available. If not + specified no effect is used. + +Parameters for ``{% plugin "tabitem" %}`` are: + +* ``tab_title``: The title of the tab. +* ``tab_bordered``: If set to ``True`` the tab will have a border. + + .. index:: single: Icon diff --git a/docs/source/conf.py b/docs/source/conf.py index 4ee1a12c..b3642b3b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -310,4 +310,4 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"https://docs.python.org/": None} +intersphinx_mapping = {'python': ('https://docs.python.org/3', None)} diff --git a/docs/source/custom_components.rst b/docs/source/custom_components.rst new file mode 100644 index 00000000..984b9987 --- /dev/null +++ b/docs/source/custom_components.rst @@ -0,0 +1,121 @@ +.. _custom_components: + +################# +Custom Components +################# + +Some frontend developers prefer custom components specifically tailored to +give the project a unique and distinct look. + +When working with `Tailwind CSS `_, for example, you +either create your custom components or customize components from providers, +e.g. `Tailwind UI `_, +`Flowbite `_, or the commiunity +`Tailwind Components `_. + +With django CMS you make your components available to the content editors for +drag and drop **and** frontend developers for use in templates from a single +source. + +To use custom components in your project, add +``"djangocms_frontend.contrib.component"`` to your ``INSTALLED_APPS`` setting. + +.. code-block:: python + + INSTALLED_APPS = [ + ... + "djangocms_frontend.contrib.component", + ... + ] + + +**djangocms-frontend** will look for custom components in the +``cms_components`` module in any of your apps. This way you can +either keep components together in one theme app, or keep them with where +they are used in different custom apps. + +Let's go through this by creating a theme app:: + + python manage.py startapp theme + +Add a ``cms_components.py`` file to the ``theme`` app: + +.. code-block:: python + + # theme/cms_components.py + from djangocms_frontend.contrib.component.components import ComponentLinkMixin, CMSFrontendComponent + from djangocms_frontend.contrib.component.components import components + from djangocms_frontend.contrib.image.fields import ImageFormField + + + @components.register + class MyHeroComponent(CMSFrontendComponent): + class Meta: + # declare plugin properties + name = "My Hero Component" + render_template = "components/hero.html" + allow_children = True + mixins = ["Background"] + # for more complex components, you can add fieldsets + + # declare fields + title = forms.CharField(required=True) + slogan = forms.CharField(required=True, widget=forms.Textarea) + hero_image = ImageFormField(required=True) + + # add description for the structure board + def get_short_description(self): + return self.title + + @components.register + class MyButton(ComponentLinkMixin, CMSFrontendComponent): + class Meta: + name = "Button" + render_template = "components/button.html" + allow_children = False + + text = forms.CharField(required=True) + + def get_short_description(self): + return self.text + +The template could be, for example: + +.. code-block:: html + + + {% load cms_tags frontend sekizai_tags %} +
+
+
+

+ {{ instance.title }} +

+

+ {{ instance.message }} +

+ {% childplugins instance %} + + Get started + + + + Speak to Sales + + {% endchildplugins %} +
+ +
+
+ {% addtoblock "js" %}{% endaddtoblock %} + +As always, django CMS manages styling and JavaScript dependencies with django-sekizai. +In this example, we add the Tailwind CSS CDN to the ``js`` block. + +.. note:: + + Components will create migrations since they use proxy models which are necessary, for + example, to manage permissions. Those migrations will be added to the app containing + the ``cms_component.py`` file. diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index b867c66c..3bce1c70 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -128,18 +128,14 @@ The example template is customisable by a set of template blocks: ``{% block content %}`` Here goes the main content of the page. The default setup is a ``
`` - with a placeholder called "Page Content" and a ``
`` with a static - placeholder (identical on all pages) called "Footer": + with a placeholder called "Page Content": .. code:: {% block content %}
{% placeholder "Page Content" %} -
  -
- {% static_placeholder "Footer" %} -
+
{% endblock content %} ``{% block navbar %}`` @@ -439,3 +435,44 @@ the upper right corner: .. image:: screenshots/tab-error-indicator.png +.. _components: + +Using frontend plugins as components in templates +================================================= + +The plugins of **djangocms-frontend** can be used as components in your +templates - even in apps that do not use or integrate with djanog CMS +otherwise. This is useful if you want use exactly the same markup for, say, +buttons, links, the grid both in pages managed with django CMS and in +other parts of your project. + +This allows you to keep one set of templates for your django CMS frontend +plugins and any changes to those templates will be reflected in all parts +of your project. + +To use a frontend plugin in a template you need to load the ``frontend`` tags +and then use the ``plugin`` template tag to render a frontend plugin. + +.. code:: + + {% load frontend %} + {% plugin "alert" alert_context="secondary" alert_dismissable=True %} + Here goes the content of the alert. + {% endplugin %} + +The plugins will be rendered based on their standard attribute settings. +You can override these settings by passing them as keyword arguments to the +``plugin`` template tag. + +See the documentation of the djanog CMS plugins for examples of how to use +the ``{% plugin %}`` template tag with each plugin. + +.. note:: + + While this is designed for **djangocms-frontend** plugins primarily, it + will work with most django CMS plugins. + + Since no plugins are created in the database, plugins relying on their + instances being available in the database will potentially not work. + This especially is true for plugins that have a foreign key to + other models. diff --git a/docs/source/grid.rst b/docs/source/grid.rst index b13e47e2..1e217be2 100644 --- a/docs/source/grid.rst +++ b/docs/source/grid.rst @@ -112,3 +112,56 @@ content. Removed: The column type entry has been removed since it was a legacy from Bootstrap version 3. + +*************************** +Re-usable component example +*************************** + +**djangocms-frontend** plugins can be used as components. They can be +used in all your project's templates. Example (if key word arguments are +skipped they fall back to their defaults): + +.. code-block:: + + {% load frontend %} + {% plugin "gridcontainer" container_type="container-fluid" %} + {% plugin "row" vertical_alignment="align-items-center" %} + {% plugin "gridcolumn" xs_col=12 md_col=6 text_alignment="center" %} + This content is inside a column. + {% endplugin %} + {% plugin "gridcolumn" xs_col=12 md_col=6 text_alignment="center" %} + This content is inside another column. + {% endplugin %} + {% endplugin %} + This content still is inside a container. + {% endplugin %} + +Parameters for ``{% plugin "gridcontainer" %}`` are: + +* ``container_type``: The type of container. Default is ``container``. Other + options are ``container-fluid`` and ``container-full``. + +Parameters for ``{% plugin "gridrow" %}`` are: + +* ``vertical_alignment``: The vertical alignment of the row. Default is + ``align-items-start``. Other options are ``align-items-center`` and + ``align-items-end``. +* ``horizontal_alignment``: The horizontal alignment of the row. Default is + ``justify-content-start``. Other options are ``justify-content-center``, + ``justify-content-end`` and ``justify-content-around``. +* ``gutters``: Size of gutter between columns. Default is ``3``. Other + options are ``0``, ``1``, ``2``, ``4``, ``5``. +* ``row_cols_xs``: Number of columns on mobile devices. +* ``row_cols{sm|md|lg|xl|xx}``: Number of columns on larger devices. + + +Parameters for ``{% plugin "gridcolumn" %}`` are: + +* ``column_alignment``: The vertical alignment of the column. Default is + ``align-self-start``. Other options are ``align-self-center`` and + ``align-self-end``. +* ``text_alignment``: The text alignment of the column. Options are + ``left``, ``center`` and ``right``. +* ``xs_col``: Number of columns on mobile devices. +* ``{sm|md|lg|xl|xx}_col``: Number of columns on larger devices. + diff --git a/docs/source/how-to/use-frontend-as-component.rst b/docs/source/how-to/use-frontend-as-component.rst new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/docs/source/how-to/use-frontend-as-component.rst @@ -0,0 +1 @@ + diff --git a/docs/source/index.rst b/docs/source/index.rst index fc6dacac..d1e7257b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -94,6 +94,7 @@ Contents getting_started grid components + custom_components how-to/index reference diff --git a/docs/source/reference.rst b/docs/source/reference.rst index a15a1212..2f3983f7 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -1,14 +1,32 @@ -########### - Reference -########### +######### +Reference +######### -********** - Settings -********** +******** +Settings +******** **djangocms-frontend** can be configured by putting the appropriate settings in your project's ``settings.py``. +.. py:attribute:: settings.CMS_COMPONENT_PLUGINS + + Defaults to ``[]`` + + A list of dotted pathes to plugin classes that are supposed to also be + components (see :ref:`components`). Components are plugins to also be + used in templates using the ``{% plugin %}`` template tag. + + For performance reason, the plugin templates are compiled at startup. + + To make **djangocms-frontend** plugins available as components, add the + following line to your project's settings:: + + CMS_COMPONENT_PLUGINS = [ + "djangocms_frontend.cms_plugins.CMSUIPlugin", # All subclasses are added + # add other plugins here if needed + ] + .. py:attribute:: settings.DJANGOCMS_FRONTEND_TAG_CHOICES Defaults to ``['div', 'section', 'article', 'header', 'footer', 'aside']``. @@ -301,6 +319,15 @@ in your project's ``settings.py``. This lost of options define the icon size choices a user can select. The values (first tuple element) are css units for the ``font-size`` css property. Besides relative units (``%``) any css unit can be used, e.g. ``112pt``. +.. py:attribute:: settings.DJANGOCMS_FRONTEND_SHOW_EMPTY_CHILDREN + + Default: ``False`` + + If set to ``True`` the frontend editing will show a message where children + can be added to plugins to complete the design. This is supposed to make + the editing experience more intuitive for editors. + + ****** Models ****** @@ -375,9 +402,9 @@ Models returns a plugin-specific short description shown in the structure mode of django CMS. -************** - Form widgets -************** +************ +Form widgets +************ **djangocms-frontend** contains button group widgets which can be used as for ``forms.ChoiceField``. They might turn out helpful when adding custom @@ -431,13 +458,9 @@ plugins. This form field is identical to the ``OptionalDeviceChoiceField`` above, but requires the user to select at least one device. - - - - -********************* - Management commands -********************* +******************* +Management commands +******************* Management commands are run by typing ``./manage.py frontend command`` in the project directory. ``command`` can be one of the following: @@ -466,9 +489,9 @@ project directory. ``command`` can be one of the following: then syncing the new permission with these commands. -*************** - Running Tests -*************** +************* +Running Tests +************* You can run tests by executing: diff --git a/private/sass/components/_button-group.scss b/private/sass/components/_button-group.scss index 69436113..c96af5b1 100644 --- a/private/sass/components/_button-group.scss +++ b/private/sass/components/_button-group.scss @@ -6,6 +6,7 @@ } .frontend-button-group { + display: inline-block; .btn { box-sizing: border-box; cursor: pointer; diff --git a/run_tests.py b/run_tests.py index c5b0b16e..cbc597a3 100755 --- a/run_tests.py +++ b/run_tests.py @@ -7,14 +7,17 @@ from django.test.utils import get_runner -def run(): +def run(argv=None): + if argv is None: + argv = ["tests"] + tests = argv[1:] if len(argv) > 1 else ["tests"] os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings" django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() - failures = test_runner.run_tests(["tests"]) + failures = test_runner.run_tests(tests) sys.exit(bool(failures)) if __name__ == "__main__": - run() + run(sys.argv) diff --git a/setup.py b/setup.py index ae2fa8bd..1e49003b 100644 --- a/setup.py +++ b/setup.py @@ -4,12 +4,11 @@ from djangocms_frontend import __version__ REQUIREMENTS = [ - "Django>=2.2", "django-cms>=3.7", "django-filer>=1.7", "easy-thumbnails", "djangocms-attributes-field>=1", - "django-entangled>=0.5.4,<0.6", + "django-entangled>=0.6", ] EXTRA_REQUIREMENTS = { @@ -28,7 +27,7 @@ ], "cms-3": [ "django-cms<4", - "djangocms-text-ckeditor>=3.1.0", + "djangocms-text", "django-parler", ], } diff --git a/tests/accordion/test_plugins.py b/tests/accordion/test_plugins.py index b71fd2f4..31514104 100644 --- a/tests/accordion/test_plugins.py +++ b/tests/accordion/test_plugins.py @@ -23,7 +23,7 @@ def test_plugin(self): with self.login_user_context(self.superuser): response = self.client.get(self.request_url) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'id="parent-1"') + self.assertContains(response, 'id="parent-') plugin = add_plugin( target=parent, diff --git a/tests/component/__init__.py b/tests/component/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/component/test_models.py b/tests/component/test_models.py new file mode 100644 index 00000000..c1fa79c8 --- /dev/null +++ b/tests/component/test_models.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from djangocms_frontend.contrib.component.registry import components + + +class ComponentPluginTestCase(TestCase): + def test_component_description(self): + + MyHero, MyHeroPlugin, _ = components["MyHero"] + + instance = MyHero.objects.create() + instance.initialize_from_form(MyHeroPlugin.form) + + self.assertEqual(instance.get_short_description(), "my title") diff --git a/tests/component/test_plugins.py b/tests/component/test_plugins.py new file mode 100644 index 00000000..61d175d6 --- /dev/null +++ b/tests/component/test_plugins.py @@ -0,0 +1,168 @@ +from cms.api import add_plugin +from cms.test_utils.testcases import CMSTestCase + +from djangocms_frontend.contrib.alert.cms_plugins import AlertPlugin +from djangocms_frontend.contrib.component.cms_plugins import ( + MyButtonPlugin, + MyHeroPlugin, + MyStrangeComponentPlugin, +) + +from ..fixtures import TestFixture + + +class ComponentPluginTestCase(TestFixture, CMSTestCase): + def test_component_with_empty_slots_plugin(self): + instance = add_plugin( + placeholder=self.placeholder, + plugin_type=MyHeroPlugin.__name__, + language=self.language, + ) + instance.initialize_from_form(MyHeroPlugin.form).save() + self.publish(self.page, self.language) + + with self.login_user_context(self.superuser): + response = self.client.get(self.request_url) + + self.assertEqual(response.status_code, 200) + # Default title is rendered in a formatted h1 tag + self.assertInHTML( + '

my title

', + response.content.decode("utf-8") + ) + # Default slogan + self.assertInHTML( + '

' + 'django CMS\' plugins are great components' + '

', + response.content.decode("utf-8") + ) + # Default slot content + self.assertInHTML( + '' + 'Get started' + '' + '', + response.content.decode("utf-8") + ) + + def test_component_with_slots_plugin(self): + from djangocms_frontend.contrib.component.cms_plugins import MyHeroSlotPlugin + + instance = add_plugin( + placeholder=self.placeholder, + plugin_type=MyHeroPlugin.__name__, + language=self.language, + ) + instance.initialize_from_form(MyHeroPlugin.form).save() + slot = add_plugin( + placeholder=self.placeholder, + target=instance, + plugin_type=MyHeroSlotPlugin.__name__, + language=self.language, + ) + add_plugin( + placeholder=self.placeholder, + target=slot, + plugin_type=AlertPlugin.__name__, + language=self.language, + ).initialize_from_form(AlertPlugin.form).save() + self.publish(self.page, self.language) + + with self.login_user_context(self.superuser): + response = self.client.get(self.request_url) + + self.assertEqual(response.status_code, 200) + + # Default title is rendered in a formatted h1 tag + self.assertInHTML( + '

my title

', + response.content.decode("utf-8") + ) + # Default slogan + self.assertInHTML( + '

' + 'django CMS\' plugins are great components' + '

', + response.content.decode("utf-8"), + count=1 + ) + + # Slot content is present + self.assertInHTML( + '', + response.content.decode("utf-8"), + count=1, + ) + # Default slot content not present + self.assertInHTML( + '' + 'Get started' + '' + '', + response.content.decode("utf-8"), + count=0 + ) + + def test_autocreate_slots(self): + from djangocms_frontend.contrib.component.cms_plugins import ( + MyHeroSlotPlugin, + MyHeroTitlePlugin, + ) + + instance = add_plugin( + placeholder=self.placeholder, + plugin_type=MyHeroPlugin.__name__, + language=self.language, + ) + request = self.get_request(path="/") + MyHeroPlugin().save_model(request=request, obj=instance, form=MyHeroPlugin.form, change=False) + + plugins = list(self.placeholder.get_plugins()) + + self.assertEqual(len(plugins), 3) + self.assertEqual(plugins[0].plugin_type, MyHeroPlugin.__name__) + self.assertIsNone(plugins[0].parent) + self.assertEqual(plugins[1].plugin_type, MyHeroTitlePlugin.__name__) + self.assertEqual(plugins[1].parent.pk, instance.pk) + self.assertEqual(plugins[2].plugin_type, MyHeroSlotPlugin.__name__) + self.assertEqual(plugins[2].parent.pk, instance.pk) + + def test_simple_component_plugin(self): + instance = add_plugin( + placeholder=self.placeholder, + plugin_type=MyButtonPlugin.__name__, + language=self.language, + ) + instance.initialize_from_form(MyButtonPlugin.form) + instance.config["internal_link"] = {"model": "cms.page", "pk": self.page.pk} + instance.save() + + link = instance.get_link() + + self.publish(self.page, self.language) + + with self.login_user_context(self.superuser): + response = self.client.get(self.request_url) + + self.assertEqual(response.status_code, 200) + self.assertInHTML(f'Click me', response.content.decode("utf-8")) + + def test_strange_plugin(self): + self.assertEqual(MyStrangeComponentPlugin.name, "MyStrangeComponent") + self.assertEqual(MyStrangeComponentPlugin.render_template, "djangocms_frontend/html_container.html") + self.assertFalse(MyStrangeComponentPlugin.allow_children) diff --git a/tests/fixtures.py b/tests/fixtures.py index f47a9f4f..a041b842 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -82,43 +82,48 @@ def create_page(self, title, **kwargs): def get_placeholders(self, page): return page.get_placeholders(self.language) - def create_url( - self, - site=None, - content_object=None, - manual_url="", - relative_path="", - phone="", - mailto="", - anchor="", - ): - from djangocms_url_manager.models import Url, UrlGrouper - from djangocms_url_manager.utils import is_versioning_enabled - from djangocms_versioning.constants import DRAFT - from djangocms_versioning.models import Version - - if site is None: - site = self.default_site - - url = Url.objects.create( - site=site, - content_object=content_object, - manual_url=manual_url, - relative_path=relative_path, - phone=phone, - mailto=mailto, - anchor=anchor, - url_grouper=UrlGrouper.objects.create(), - ) - if is_versioning_enabled(): - Version.objects.create( - content=url, - created_by=self.superuser, - state=DRAFT, - content_type_id=ContentType.objects.get_for_model(Url).id, + try: + import djangocms_url_manager as __just_testing__ + + def create_url( + self, + site=None, + content_object=None, + manual_url="", + relative_path="", + phone="", + mailto="", + anchor="", + ): + from djangocms_url_manager.models import Url, UrlGrouper + from djangocms_url_manager.utils import is_versioning_enabled + from djangocms_versioning.constants import DRAFT + from djangocms_versioning.models import Version + + if site is None: + site = self.default_site + + url = Url.objects.create( + site=site, + content_object=content_object, + manual_url=manual_url, + relative_path=relative_path, + phone=phone, + mailto=mailto, + anchor=anchor, + url_grouper=UrlGrouper.objects.create(), ) - - return url + if is_versioning_enabled(): + Version.objects.create( + content=url, + created_by=self.superuser, + state=DRAFT, + content_type_id=ContentType.objects.get_for_model(Url).id, + ) + + return url + except ModuleNotFoundError: + pass def delete_urls(self): from djangocms_url_manager.models import Url diff --git a/tests/grid/test_plugins.py b/tests/grid/test_plugins.py index c2e77484..830c3328 100644 --- a/tests/grid/test_plugins.py +++ b/tests/grid/test_plugins.py @@ -133,7 +133,7 @@ def test_row_plugin_creation(self): form = GridRowForm( {"config": {"a": 1}, **data} ) # GridRowForm & GridColumnForm need config explicitly not empty - self.assertTrue(form.is_valid()) + self.assertTrue(form.is_valid(), f"{form.__class__.__name__}:form errors: {form.errors}") if ( not DJANGO_CMS4 diff --git a/tests/image/test_drag_n_drop.py b/tests/image/test_drag_n_drop.py index e6f91216..e0faf839 100644 --- a/tests/image/test_drag_n_drop.py +++ b/tests/image/test_drag_n_drop.py @@ -25,7 +25,7 @@ def test_extract_images(self): id = picture_plugins[0].id self.assertHTMLEqual( text_plugin.body, - f'', ) diff --git a/tests/image/test_plugins.py b/tests/image/test_plugins.py index af9b098c..01697e5d 100644 --- a/tests/image/test_plugins.py +++ b/tests/image/test_plugins.py @@ -45,7 +45,7 @@ def test_plugin(self): plugin_type=ImagePlugin.__name__, language=self.language, config={ - "image": {"pk": self.image.id, "model": "filer.Image"}, + "picture": {"pk": self.image.id, "model": "filer.Image"}, "picture_fluid": False, "picture_rounded": True, "picture_thumbnail": True, @@ -74,7 +74,7 @@ def test_image_form(self): "margin_devices": ["xs"], } form = ImageForm(request.POST) - self.assertTrue(form.is_valid()) + self.assertTrue(form.is_valid(), f"{form.__class__.__name__}:form errors: {form.errors}") self.assertEqual(form.cleaned_data["config"]["use_responsive_image"], "yes") request.POST.update( diff --git a/tests/link/test_plugins.py b/tests/link/test_plugins.py index 70ae0cd4..62646e82 100644 --- a/tests/link/test_plugins.py +++ b/tests/link/test_plugins.py @@ -8,7 +8,7 @@ from djangocms_frontend.contrib.link.forms import LinkForm, SmartLinkField from djangocms_frontend.contrib.link.helpers import get_choices -from ..fixtures import DJANGO_CMS4, TestFixture +from ..fixtures import TestFixture class LinkPluginTestCase(TestFixture, CMSTestCase): @@ -71,6 +71,8 @@ def test_plugin(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "btn-primary") self.assertContains(response, 'href="/content/"') + # Finally, test the descriptor + self.assertEqual(plugin.internal_link, self.page) # alternate version broken link plugin = add_plugin( @@ -137,14 +139,14 @@ def test_link_form(self): self.create_url(manual_url="https://www.django-cms.org/").id ) ) - if DJANGO_CMS4 + if hasattr(self, "create_url") else dict(external_link="https://www.django-cms.org/") ), } ) form = LinkForm(request.POST) - self.assertTrue(form.is_valid()) - if DJANGO_CMS4: + self.assertTrue(form.is_valid(), f"{form.__class__.__name__}:form errors: {form.errors}") + if hasattr(self, "create_url"): self.delete_urls() else: request.POST.update({"mailto": "none@nowhere.com"}) diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt index 1c85a318..9f16ee5d 100644 --- a/tests/requirements/base.txt +++ b/tests/requirements/base.txt @@ -8,3 +8,6 @@ pyflakes>=2.1 wheel black pre-commit +djangocms-text +html5lib +. diff --git a/tests/requirements/dj32_cms310.txt b/tests/requirements/dj32_cms310.txt deleted file mode 100644 index 3f663bd0..00000000 --- a/tests/requirements/dj32_cms310.txt +++ /dev/null @@ -1,5 +0,0 @@ --r base.txt - -Django>=3.2,<3.3 -django-cms>=3.10, <3.11 -djangocms-text-ckeditor diff --git a/tests/requirements/dj32_cms38.txt b/tests/requirements/dj32_cms38.txt deleted file mode 100644 index 4d97979e..00000000 --- a/tests/requirements/dj32_cms38.txt +++ /dev/null @@ -1,6 +0,0 @@ --r base.txt - -Django>=3.2,<3.3 -django-cms>=3.8,<3.9 -django-treebeard<4.5 -djangocms-text-ckeditor diff --git a/tests/requirements/dj32_cms39.txt b/tests/requirements/dj32_cms39.txt deleted file mode 100644 index 1a020f46..00000000 --- a/tests/requirements/dj32_cms39.txt +++ /dev/null @@ -1,5 +0,0 @@ --r base.txt - -Django>=3.2,<3.3 -django-cms>=3.9,<4.0 -djangocms-text-ckeditor diff --git a/tests/requirements/dj32_cms41.txt b/tests/requirements/dj32_cms41.txt deleted file mode 100644 index 84fddd83..00000000 --- a/tests/requirements/dj32_cms41.txt +++ /dev/null @@ -1,8 +0,0 @@ --r base.txt - -Django>=3.2,<4.0 -django-cms>=4.1rc2 -djangocms-text-ckeditor --e git+https://github.com/fsbraun/djangocms-alias.git@master#egg=djangocms-alias --e git+https://github.com/fsbraun/djangocms-url-manager.git@master#egg=djangocms-url-manager -https://github.com/django-cms/djangocms-versioning/tarball/master#egg=djangocms-versioning diff --git a/tests/requirements/dj40_cms311.txt b/tests/requirements/dj40_cms311.txt deleted file mode 100644 index 3d2450a0..00000000 --- a/tests/requirements/dj40_cms311.txt +++ /dev/null @@ -1,6 +0,0 @@ --r base.txt - -django-filer>=2.2 -Django>=4.0,<4.1 -django-cms>=3.11,<4.0 -djangocms-text-ckeditor diff --git a/tests/requirements/dj40_cms41.txt b/tests/requirements/dj40_cms41.txt deleted file mode 100644 index db5deb4b..00000000 --- a/tests/requirements/dj40_cms41.txt +++ /dev/null @@ -1,8 +0,0 @@ --r base.txt - -Django>=4.0,<4.1 -django-cms>=4.1rc2 -djangocms-text-ckeditor --e git+https://github.com/fsbraun/djangocms-alias.git@master#egg=djangocms-alias --e git+https://github.com/fsbraun/djangocms-url-manager.git@master#egg=djangocms-url-manager -https://github.com/django-cms/djangocms-versioning/tarball/master#egg=djangocms-versioning diff --git a/tests/requirements/dj41_cms311.txt b/tests/requirements/dj41_cms311.txt deleted file mode 100644 index 433d812b..00000000 --- a/tests/requirements/dj41_cms311.txt +++ /dev/null @@ -1,6 +0,0 @@ --r base.txt - -django-filer>=2.2 -Django>=4.1,<4.2 -django-cms>=3.11,<3.12 -djangocms-text-ckeditor diff --git a/tests/requirements/dj41_cms41.txt b/tests/requirements/dj41_cms41.txt deleted file mode 100644 index 53fa5ba8..00000000 --- a/tests/requirements/dj41_cms41.txt +++ /dev/null @@ -1,8 +0,0 @@ --r base.txt - -Django>=4.1,<4.2 -django-cms>=4.1 -djangocms-alias>=2.0.0 -djangocms-versioning>=2.0.0 -git+https://github.com/fsbraun/djangocms-url-manager.git@master#egg=djangocms-url-manager -djangocms-text-ckeditor diff --git a/tests/requirements/dj42_cms311.txt b/tests/requirements/dj42_cms311.txt index 74095fac..2fb91bc7 100644 --- a/tests/requirements/dj42_cms311.txt +++ b/tests/requirements/dj42_cms311.txt @@ -3,4 +3,3 @@ django-filer>=2.2 Django>=4.2,<5 django-cms>=3.11,<3.12 -djangocms-text-ckeditor diff --git a/tests/requirements/dj42_cms41.txt b/tests/requirements/dj42_cms41.txt index 97b3194f..02f63868 100644 --- a/tests/requirements/dj42_cms41.txt +++ b/tests/requirements/dj42_cms41.txt @@ -1,8 +1,6 @@ -r base.txt Django>=4.2,<4.3 -django-cms>=4.1rc2 -djangocms-alias>=2.0.0 +django-cms>=4.1,<4.2 djangocms-versioning>=2.0.0 git+https://github.com/fsbraun/djangocms-url-manager.git@master#egg=djangocms-url-manager -djangocms-text-ckeditor diff --git a/tests/requirements/dj50_cms41.txt b/tests/requirements/dj50_cms41.txt index 04c030c8..6ac051e2 100644 --- a/tests/requirements/dj50_cms41.txt +++ b/tests/requirements/dj50_cms41.txt @@ -1,8 +1,6 @@ -r base.txt Django>=5.0,<5.1 -django-cms>=4.1rc5 -djangocms-alias>=2.0.0 +django-cms>=4.1,<4.2 djangocms-versioning>=2.0.0 git+https://github.com/fsbraun/djangocms-url-manager.git@master#egg=djangocms-url-manager -djangocms-text-ckeditor diff --git a/tests/requirements/dj51_cms41.txt b/tests/requirements/dj51_cms41.txt new file mode 100644 index 00000000..ee3c5a17 --- /dev/null +++ b/tests/requirements/dj51_cms41.txt @@ -0,0 +1,5 @@ +-r base.txt + +Django>=5.1,<5.2 +django-cms>=4.1.1,<4.2 +djangocms-versioning>=2.0.1 diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/cms_components.py b/tests/test_app/cms_components.py new file mode 100644 index 00000000..20890102 --- /dev/null +++ b/tests/test_app/cms_components.py @@ -0,0 +1,50 @@ +from django import forms + +from djangocms_frontend.contrib.component.components import ( + CMSFrontendComponent, + ComponentLinkMixin, +) +from djangocms_frontend.contrib.component.registry import components +from djangocms_frontend.contrib.image.fields import ImageFormField + + +@components.register +class MyHero(CMSFrontendComponent): + class Meta: + # declare plugin properties + name = "My Hero Component" + render_template = "hero.html" + allow_children = True + mixins = ["Background"] + slots = ( + ("title", "Title"), + ("slot", "Slot"), + ) + # for more complex components, you can add fieldsets + + # declare fields + title = forms.CharField(required=True, initial="my title") + slogan = forms.CharField(required=True, initial="django CMS' plugins are great components", widget=forms.Textarea) + image = ImageFormField(required=True) + + # add description for the structure board + def get_short_description(self): + return self.title + + +@components.register +class MyButton(ComponentLinkMixin, CMSFrontendComponent): + class Meta: + name = "Button" + render_template = "button.html" + allow_children = False + + text = forms.CharField(required=True, initial="Click me") + + def get_short_description(self): + return self.text + + +@components.register +class MyStrangeComponent(CMSFrontendComponent): + text = forms.CharField(required=True, initial="Message") diff --git a/tests/test_app/migrations/__init__.py b/tests/test_app/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/templates/button.html b/tests/test_app/templates/button.html new file mode 100644 index 00000000..218fe92a --- /dev/null +++ b/tests/test_app/templates/button.html @@ -0,0 +1 @@ +{{ instance.text }} diff --git a/tests/test_app/templates/hero.html b/tests/test_app/templates/hero.html new file mode 100644 index 00000000..ed967704 --- /dev/null +++ b/tests/test_app/templates/hero.html @@ -0,0 +1,27 @@ + +{% load cms_tags frontend sekizai_tags %} +
+
+
+

+ {% childplugins instance "title" %}{{ instance.title }}{% endchildplugins %} +

+

+ {% childplugins instance "slogan" %}{{ instance.slogan }}{% endchildplugins %} +

+ {% childplugins instance "slot" %} + + Get started + + + + Speak to Sales + + {% endchildplugins %} +
+ +
+
+{% addtoblock "js" %}{% endaddtoblock %} diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 3613e982..e7d0e730 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -25,5 +25,5 @@ def test_for_missing_migrations(self): # the "no changes" exit code is 0 status_code = "0" - if status_code == "1": + if status_code == "1" and output.getvalue(): self.fail(f"There are missing migrations:\n {output.getvalue()}") diff --git a/tests/test_plugin_tag.py b/tests/test_plugin_tag.py new file mode 100644 index 00000000..4031f9db --- /dev/null +++ b/tests/test_plugin_tag.py @@ -0,0 +1,123 @@ +from cms.test_utils.testcases import CMSTestCase +from django.contrib.sites.shortcuts import get_current_site +from django.template import engines +from django.test import override_settings + +from tests.fixtures import TestFixture + +django_engine = engines["django"] + + +@override_settings(DEBUG=True) +class PluginTagTestCase(TestFixture, CMSTestCase): + def test_tag_default_rendering(self): + template = django_engine.from_string(""" + {% load frontend cms_tags %} + {% plugin "alert" %}Alert{% endplugin %} + """) + + result = template.render({"request": None}) + + self.assertInHTML('', result) + + def test_tag_rendering_with_paramter(self): + template = django_engine.from_string(""" + {% load frontend cms_tags %} + {% plugin "alert" alert_context="secondary" alert_dismissible=True %}Alert{% endplugin %} + """) + expected_result = """""" + + result = template.render({"request": None}) + + self.assertInHTML(expected_result, result) + + def test_simple_tag(self): + template = django_engine.from_string(""" + {% load frontend %} + {% plugin "badge" badge_text="My badge" badge_context="info" badge_pills=False %} + This content is ignored. + {% endplugin %}""") + expected_result = """My badge""" + + result = template.render({"request": None}) + + self.assertInHTML(expected_result, result) + + def test_complex_tags(self): + template = django_engine.from_string("""{% load frontend %} + {% plugin "card" card_alignment="center" card_outline="info" card_text_color="primary" card_full_height=True %} + {% plugin "cardinner" inner_type="card-header" text_alignment="start" %} +

Card title

+ {% endplugin %} + {% plugin "cardinner" inner_type="card-body" text_alignment="center" %} + Some quick example text to build on the card title and make up the + bulk of the card's content. + {% endplugin %} + {% plugin "listgroupitem" %}An item{% endplugin %} + {% plugin "listgroupitem" %}A second item{% endplugin %} + {% plugin "listgroupitem" %}A third item{% endplugin %} + {% endplugin %}""") + + expected_result = """ +
+

Card title

+
+ Some quick example text to build on the card title and make up the + bulk of the card's content. +
+
An item
+
A second item
+
A third item
+
""" + result = template.render({"request": None}) + + self.assertInHTML(expected_result, result) + + def test_link_plugin(self): + if hasattr(self, "create_url"): + grouper = self.create_url(manual_url="/test/").url_grouper + template = django_engine.from_string("""{% load frontend %} + {% plugin "textlink" name="Click" url_grouper=grouper site=test_site link_type="btn" link_context="primary" link_outline=False %} + Click me! + {% endplugin %} + """) # noqa: B950 + else: + grouper = None + template = django_engine.from_string("""{% load frontend %} + {% plugin "textlink" name="Click" external_link="/test/" link_type="btn" link_context="primary" link_outline=False %} + Click me! + {% endplugin %} + """) # noqa: B950 + + expected_result = """Click me!""" + + result = template.render({"request": None, "test_site": get_current_site(None), "grouper": grouper}) + + self.assertInHTML(expected_result, result) + + @override_settings(DEBUG=True) + def test_non_existing_plugin(self): + template = django_engine.from_string("""{% load frontend %} + {% plugin "nonexisting" %} + This should not be rendered. + {% endplugin %} + """) + expected_result = "" + + result = template.render({"request": None}) + + self.assertEqual(expected_result.strip(), result.strip()) + + def test_non_frontend_plugin(self): + template = django_engine.from_string("""{% load frontend %} + {% plugin "text" body="

my text

" %} + This should not be rendered. + {% endplugin %} + """) + expected_result = "

my text

" + + result = template.render({"request": None}) + + self.assertInHTML(expected_result, result) diff --git a/tests/test_settings.py b/tests/test_settings.py index b5792bef..b7645be9 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -14,7 +14,7 @@ "cms", "menus", "treebeard", - "djangocms_text_ckeditor", + "djangocms_text", "djangocms_frontend", "djangocms_frontend.contrib.accordion", "djangocms_frontend.contrib.alert", @@ -22,6 +22,7 @@ "djangocms_frontend.contrib.card", "djangocms_frontend.contrib.carousel", "djangocms_frontend.contrib.collapse", + "djangocms_frontend.contrib.component", "djangocms_frontend.contrib.content", "djangocms_frontend.contrib.grid", "djangocms_frontend.contrib.icon", @@ -34,6 +35,7 @@ "djangocms_frontend.contrib.tabs", "djangocms_frontend.contrib.utilities", "sekizai", + "tests.test_app", ] if DJANGO_3_1: @@ -44,7 +46,14 @@ INSTALLED_APPS += [ "djangocms_versioning", - "djangocms_alias", + ] +except ImportError: # Nope + pass + +try: # url manager test? + import djangocms_url_manager # noqa + + INSTALLED_APPS += [ "djangocms_url_manager", ] except ImportError: # Nope @@ -121,3 +130,8 @@ CMS_CONFIRM_VERSION4 = True # Needed for v4, neglected in v3 TEXT_SAVE_IMAGE_FUNCTION = 'djangocms_frontend.contrib.image.image_save.create_image_plugin' + +CMS_COMPONENT_PLUGINS = [ + "djangocms_frontend.cms_plugins.CMSUIPlugin", + "djangocms_text.cms_plugins.TextPlugin", +] diff --git a/tests/utilities/test_plugins.py b/tests/utilities/test_plugins.py index ddb716cf..ee3f6987 100644 --- a/tests/utilities/test_plugins.py +++ b/tests/utilities/test_plugins.py @@ -1,5 +1,6 @@ from cms.api import add_plugin from cms.test_utils.testcases import CMSTestCase +from cms.utils.urlutils import admin_reverse from djangocms_frontend.contrib.utilities.cms_plugins import ( HeadingPlugin, @@ -87,3 +88,27 @@ def test_heading(self): self.assertContains(response, '