diff --git a/cast/blocks.py b/cast/blocks.py index fc40ef32..a19770a8 100644 --- a/cast/blocks.py +++ b/cast/blocks.py @@ -6,15 +6,15 @@ from django.template.loader import TemplateDoesNotExist, get_template from django.utils.functional import cached_property from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import ClassNotFound, get_lexer_by_name -from wagtail.blocks import CharBlock, ChooserBlock, ListBlock, StructBlock, TextBlock -from wagtail.images.blocks import ImageChooserBlock +from wagtail.blocks import CharBlock, ChoiceBlock, ListBlock, StructBlock, TextBlock +from wagtail.images.blocks import ChooserBlock, ImageChooserBlock from wagtail.images.models import AbstractImage, AbstractRendition from . import appsettings as settings -from .models import Gallery from .renditions import ( Height, ImageForSlot, @@ -108,49 +108,99 @@ def get_context(self, image: AbstractImage, parent_context: Optional[dict] = Non return super().get_context(image, parent_context=parent_context) +def add_prev_next(images: QuerySet[AbstractImage]) -> None: + """ + For each image in the queryset, add the previous and next image. + """ + for previous_image, current_image, next_image in previous_and_next(images): + current_image.prev = "false" if previous_image is None else f"img-{previous_image.pk}" + current_image.next = "false" if next_image is None else f"img-{next_image.pk}" + + +def add_image_thumbnails(images: QuerySet[AbstractImage], context: dict) -> None: + """ + For each image in the queryset, add the thumbnail and modal image data to the image. + """ + modal_slot, thumbnail_slot = ( + Rectangle(Width(w), Height(h)) for w, h in settings.CAST_GALLERY_IMAGE_SLOT_DIMENSIONS + ) + for image in images: + fetched_renditions = { + r.filter_spec: r for r in context.get("renditions_for_posts", {}) if r.image_id == image.pk + } + images_for_slots = get_srcset_images_for_slots(image, "gallery", fetched_renditions=fetched_renditions) + image.modal = images_for_slots[modal_slot] + image.thumbnail = images_for_slots[thumbnail_slot] + + +def prepare_context_for_gallery(images: QuerySet[AbstractImage], context: dict) -> dict: + """ + Add the previous and next image and the thumbnail and modal image data to each + image of the gallery and then the images to the context. + """ + add_prev_next(images) + add_image_thumbnails(images, context=context) + context["images"] = images + return context + + +def get_gallery_block_template(default_template_name: str, context: Optional[dict]) -> str: + if context is None: + return default_template_name + + template_base_dir = context.get("template_base_dir") + if template_base_dir is None: + return default_template_name + + template_from_theme = f"cast/{template_base_dir}/gallery.html" + try: + get_template(template_from_theme) + return template_from_theme + except TemplateDoesNotExist: + return default_template_name + + class GalleryBlock(ListBlock): - default_template_name = "cast/gallery.html" + class Meta: + icon = "image" + label = "Gallery" + template = "cast/gallery.html" - @staticmethod - def add_prev_next(gallery: QuerySet[Gallery]) -> None: - for previous_image, current_image, next_image in previous_and_next(gallery): - current_image.prev = "false" if previous_image is None else f"img-{previous_image.pk}" - current_image.next = "false" if next_image is None else f"img-{next_image.pk}" + def get_template(self, images: Optional[QuerySet[AbstractImage]] = None, context: Optional[dict] = None) -> str: + default_template_name = super().get_template(images, context) + return get_gallery_block_template(default_template_name, context) - def get_template(self, context: Optional[dict] = None) -> str: - if context is None: - return self.default_template_name + def get_context(self, images: QuerySet[AbstractImage], parent_context: Optional[dict] = None) -> dict: + context = super().get_context(images, parent_context=parent_context) + return prepare_context_for_gallery(images, context) - template_base_dir = context.get("template_base_dir") - if template_base_dir is None: - return self.default_template_name - template_from_theme = f"cast/{template_base_dir}/gallery.html" - try: - get_template(template_from_theme) - return template_from_theme - except TemplateDoesNotExist: - return self.default_template_name - - @staticmethod - def add_image_thumbnails(gallery: QuerySet[Gallery], parent_context: dict) -> None: - modal_slot, thumbnail_slot = ( - Rectangle(Width(w), Height(h)) for w, h in settings.CAST_GALLERY_IMAGE_SLOT_DIMENSIONS - ) - for image in gallery: - fetched_renditions = { - r.filter_spec: r for r in parent_context.get("renditions_for_posts", {}) if r.image_id == image.pk - } - images_for_slots = get_srcset_images_for_slots(image, "gallery", fetched_renditions=fetched_renditions) - image.modal = images_for_slots[modal_slot] - image.thumbnail = images_for_slots[thumbnail_slot] - - def get_context(self, gallery: QuerySet[Gallery], parent_context: Optional[dict] = None) -> dict: - if parent_context is None: - parent_context = {} - self.add_prev_next(gallery) - self.add_image_thumbnails(gallery, parent_context=parent_context) - return super().get_context(gallery, parent_context=parent_context) +class GalleryBlockWithLayout(StructBlock): + """ + A gallery block with a layout. The layout parameter controls + which template is used to render the gallery. + """ + + gallery = GalleryBlock(ImageChooserBlock()) + layout = ChoiceBlock( + choices=[ + ("default", _("Web Component with Modal")), + ("htmx", _("HTMX based layout")), + ], + default="default", + ) + + class Meta: + icon = "image" + label = "Gallery with Layout" + + def get_template(self, value=None, context=None): + default_template_name = super().get_template(value, context) + return get_gallery_block_template(default_template_name, context) + + def get_context(self, value, parent_context: Optional[dict] = None): + context = super().get_context(value, parent_context=parent_context) + return prepare_context_for_gallery(value["gallery"], context) class VideoChooserBlock(ChooserBlock): diff --git a/cast/migrations/0052_alter_blog_template_base_dir_alter_post_body_and_more.py b/cast/migrations/0052_alter_blog_template_base_dir_alter_post_body_and_more.py new file mode 100644 index 00000000..19d765a2 --- /dev/null +++ b/cast/migrations/0052_alter_blog_template_base_dir_alter_post_body_and_more.py @@ -0,0 +1,216 @@ +# Generated by Django 5.0 on 2023-12-22 22:25 + +import cast.blocks +import wagtail.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cast", "0051_use_own_image_chooser_block"), + ] + + operations = [ + migrations.AlterField( + model_name="blog", + name="template_base_dir", + field=models.CharField( + blank=True, + choices=[ + ("bootstrap4", "Bootstrap 4"), + ("plain", "Just HTML"), + ("vue", "Vue.js"), + ("bootstrap5", "Bootstrap 5"), + ], + default=None, + help_text="The theme to use for this blog implemented as a template base directory. If not set, the template base directory will be determined by a site setting.", + max_length=128, + null=True, + ), + ), + migrations.AlterField( + model_name="post", + name="body", + field=wagtail.fields.StreamField( + [ + ( + "overview", + wagtail.blocks.StreamBlock( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title" + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ( + "code", + wagtail.blocks.StructBlock( + [ + ( + "language", + wagtail.blocks.CharBlock( + help_text="The language of the code block" + ), + ), + ( + "source", + wagtail.blocks.TextBlock( + help_text="The source code of the block", + rows=8, + ), + ), + ], + icon="code", + ), + ), + ( + "image", + cast.blocks.CastImageChooserBlock( + template="cast/image/image.html" + ), + ), + ( + "gallery", + wagtail.blocks.StructBlock( + [ + ( + "gallery", + cast.blocks.GalleryBlock( + wagtail.images.blocks.ImageChooserBlock() + ), + ), + ( + "layout", + wagtail.blocks.ChoiceBlock( + choices=[ + ( + "web_component", + "Web Component with Modal", + ), + ("htmx", "HTMX based layout"), + ] + ), + ), + ] + ), + ), + ("embed", wagtail.embeds.blocks.EmbedBlock()), + ( + "video", + cast.blocks.VideoChooserBlock( + icon="media", template="cast/video/video.html" + ), + ), + ( + "audio", + cast.blocks.AudioChooserBlock( + icon="media", template="cast/audio/audio.html" + ), + ), + ] + ), + ), + ( + "detail", + wagtail.blocks.StreamBlock( + [ + ( + "heading", + wagtail.blocks.CharBlock( + form_classname="full title" + ), + ), + ("paragraph", wagtail.blocks.RichTextBlock()), + ( + "code", + wagtail.blocks.StructBlock( + [ + ( + "language", + wagtail.blocks.CharBlock( + help_text="The language of the code block" + ), + ), + ( + "source", + wagtail.blocks.TextBlock( + help_text="The source code of the block", + rows=8, + ), + ), + ], + icon="code", + ), + ), + ( + "image", + cast.blocks.CastImageChooserBlock( + template="cast/image/image.html" + ), + ), + ( + "gallery", + wagtail.blocks.StructBlock( + [ + ( + "gallery", + cast.blocks.GalleryBlock( + wagtail.images.blocks.ImageChooserBlock() + ), + ), + ( + "layout", + wagtail.blocks.ChoiceBlock( + choices=[ + ( + "web_component", + "Web Component with Modal", + ), + ("htmx", "HTMX based layout"), + ] + ), + ), + ] + ), + ), + ("embed", wagtail.embeds.blocks.EmbedBlock()), + ( + "video", + cast.blocks.VideoChooserBlock( + icon="media", template="cast/video/video.html" + ), + ), + ( + "audio", + cast.blocks.AudioChooserBlock( + icon="media", template="cast/audio/audio.html" + ), + ), + ] + ), + ), + ], + use_json_field=True, + ), + ), + migrations.AlterField( + model_name="templatebasedirectory", + name="name", + field=models.CharField( + choices=[ + ("bootstrap4", "Bootstrap 4"), + ("plain", "Just HTML"), + ("vue", "Vue.js"), + ("bootstrap5", "Bootstrap 5"), + ], + default="bootstrap4", + help_text="The theme to use for this site implemented as a template base directory. It's possible to overwrite this setting for each blog.If you want to use a custom theme, you have to create a new directory in your template directory named cast// and put all required templates in there.", + max_length=128, + ), + ), + ] diff --git a/cast/migrations/0053_rename_default_layout.py b/cast/migrations/0053_rename_default_layout.py new file mode 100644 index 00000000..55cabd65 --- /dev/null +++ b/cast/migrations/0053_rename_default_layout.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2023-12-23 08:13 + +import cast.blocks +import wagtail.blocks +import wagtail.embeds.blocks +import wagtail.fields +import wagtail.images.blocks +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('cast', '0052_alter_blog_template_base_dir_alter_post_body_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='post', + name='body', + field=wagtail.fields.StreamField([('overview', wagtail.blocks.StreamBlock([('heading', wagtail.blocks.CharBlock(form_classname='full title')), ('paragraph', wagtail.blocks.RichTextBlock()), ('code', wagtail.blocks.StructBlock([('language', wagtail.blocks.CharBlock(help_text='The language of the code block')), ('source', wagtail.blocks.TextBlock(help_text='The source code of the block', rows=8))], icon='code')), ('image', cast.blocks.CastImageChooserBlock(template='cast/image/image.html')), ('gallery', wagtail.blocks.StructBlock([('gallery', cast.blocks.GalleryBlock(wagtail.images.blocks.ImageChooserBlock())), ('layout', wagtail.blocks.ChoiceBlock(choices=[('default', 'Web Component with Modal'), ('htmx', 'HTMX based layout')]))])), ('embed', wagtail.embeds.blocks.EmbedBlock()), ('video', cast.blocks.VideoChooserBlock(icon='media', template='cast/video/video.html')), ('audio', cast.blocks.AudioChooserBlock(icon='media', template='cast/audio/audio.html'))])), ('detail', wagtail.blocks.StreamBlock([('heading', wagtail.blocks.CharBlock(form_classname='full title')), ('paragraph', wagtail.blocks.RichTextBlock()), ('code', wagtail.blocks.StructBlock([('language', wagtail.blocks.CharBlock(help_text='The language of the code block')), ('source', wagtail.blocks.TextBlock(help_text='The source code of the block', rows=8))], icon='code')), ('image', cast.blocks.CastImageChooserBlock(template='cast/image/image.html')), ('gallery', wagtail.blocks.StructBlock([('gallery', cast.blocks.GalleryBlock(wagtail.images.blocks.ImageChooserBlock())), ('layout', wagtail.blocks.ChoiceBlock(choices=[('default', 'Web Component with Modal'), ('htmx', 'HTMX based layout')]))])), ('embed', wagtail.embeds.blocks.EmbedBlock()), ('video', cast.blocks.VideoChooserBlock(icon='media', template='cast/video/video.html')), ('audio', cast.blocks.AudioChooserBlock(icon='media', template='cast/audio/audio.html'))]))], use_json_field=True), + ), + ] diff --git a/cast/models/pages.py b/cast/models/pages.py index 1d259ebd..8c276a65 100644 --- a/cast/models/pages.py +++ b/cast/models/pages.py @@ -38,6 +38,7 @@ CastImageChooserBlock, CodeBlock, GalleryBlock, + GalleryBlockWithLayout, VideoChooserBlock, ) from cast.models import get_or_create_gallery @@ -59,7 +60,7 @@ class ContentBlock(blocks.StreamBlock): paragraph: blocks.RichTextBlock = blocks.RichTextBlock() code: CodeBlock = CodeBlock(icon="code") image: CastImageChooserBlock = CastImageChooserBlock(template="cast/image/image.html") - gallery: GalleryBlock = GalleryBlock(ImageChooserBlock()) + gallery: GalleryBlockWithLayout = GalleryBlockWithLayout() embed: EmbedBlock = EmbedBlock() video: VideoChooserBlock = VideoChooserBlock(template="cast/video/video.html", icon="media") audio: AudioChooserBlock = AudioChooserBlock(template="cast/audio/audio.html", icon="media") @@ -297,7 +298,8 @@ def _media_ids_from_body(self, body: StreamField) -> TypeToIdSet: for content_block in body: for block in content_block.value: if block.block_type == "gallery": - image_ids = [i.id for i in block.value] + images = block.value.get("gallery", []) + image_ids = [i.id for i in images] media_model = get_or_create_gallery(image_ids) else: media_model = block.value diff --git a/cast/templates/cast/bootstrap4/gallery.html b/cast/templates/cast/bootstrap4/gallery.html index bfb5b66c..cccb3532 100644 --- a/cast/templates/cast/bootstrap4/gallery.html +++ b/cast/templates/cast/bootstrap4/gallery.html @@ -1,6 +1,6 @@