diff --git a/app/base/__init__.py b/app/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/base/apps.py b/app/base/apps.py new file mode 100644 index 0000000..141b278 --- /dev/null +++ b/app/base/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BaseConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.base" diff --git a/app/base/blocks.py b/app/base/blocks.py new file mode 100644 index 0000000..a70ed80 --- /dev/null +++ b/app/base/blocks.py @@ -0,0 +1,48 @@ +from wagtail.blocks import ( + CharBlock, + ChoiceBlock, + RichTextBlock, + StreamBlock, + StructBlock, +) +from wagtail.embeds.blocks import EmbedBlock +from wagtail.images.blocks import ImageBlock + + +class CaptionedImageBlock(StructBlock): + image = ImageBlock(required=True) + caption = CharBlock(required=False) + attribution = CharBlock(required=False) + + class Meta: + icon = "image" + template = "base/blocks/captioned_image_block.html" + + +class HeadingBlock(StructBlock): + heading_text = CharBlock(classname="title", required=True) + size = ChoiceBlock( + choices=[ + ("", "Select a heading size"), + ("h2", "H2"), + ("h3", "H3"), + ("h4", "H4"), + ], + blank=True, + required=False, + ) + + class Meta: + icon = "title" + template = "base/blocks/heading_block.html" + + +class BaseStreamBlock(StreamBlock): + heading_block = HeadingBlock() + paragraph_block = RichTextBlock(icon="pilcrow") + image_block = CaptionedImageBlock() + embed_block = EmbedBlock( + help_text="Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks", + icon="media", + template="base/blocks/embed_block.html", + ) diff --git a/app/base/migrations/0001_create_footer.py b/app/base/migrations/0001_create_footer.py new file mode 100644 index 0000000..585c65c --- /dev/null +++ b/app/base/migrations/0001_create_footer.py @@ -0,0 +1,152 @@ +# Generated by Django 5.1.4 on 2024-12-14 17:23 + +import uuid + +import django.db.models.deletion +import wagtail.fields +import wagtail.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="NavigationSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "linkedin_url", + models.URLField(blank=True, verbose_name="LinkedIn URL"), + ), + ("github_url", models.URLField(blank=True, verbose_name="GitHub URL")), + ( + "mastodon_url", + models.URLField(blank=True, verbose_name="Mastodon URL"), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="FooterText", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "translation_key", + models.UUIDField(default=uuid.uuid4, editable=False), + ), + ( + "live", + models.BooleanField( + default=True, editable=False, verbose_name="live" + ), + ), + ( + "has_unpublished_changes", + models.BooleanField( + default=False, + editable=False, + verbose_name="has unpublished changes", + ), + ), + ( + "first_published_at", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="first published at", + ), + ), + ( + "last_published_at", + models.DateTimeField( + editable=False, null=True, verbose_name="last published at" + ), + ), + ( + "go_live_at", + models.DateTimeField( + blank=True, null=True, verbose_name="go live date/time" + ), + ), + ( + "expire_at", + models.DateTimeField( + blank=True, null=True, verbose_name="expiry date/time" + ), + ), + ( + "expired", + models.BooleanField( + default=False, editable=False, verbose_name="expired" + ), + ), + ("body", wagtail.fields.RichTextField()), + ( + "latest_revision", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.revision", + verbose_name="latest revision", + ), + ), + ( + "live_revision", + models.ForeignKey( + blank=True, + editable=False, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.revision", + verbose_name="live revision", + ), + ), + ( + "locale", + models.ForeignKey( + editable=False, + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="wagtailcore.locale", + verbose_name="locale", + ), + ), + ], + options={ + "verbose_name_plural": "Footer Text", + "abstract": False, + "unique_together": {("translation_key", "locale")}, + }, + bases=(wagtail.models.PreviewableMixin, models.Model), + ), + ] diff --git a/app/base/migrations/0002_add_form_page.py b/app/base/migrations/0002_add_form_page.py new file mode 100644 index 0000000..054ed08 --- /dev/null +++ b/app/base/migrations/0002_add_form_page.py @@ -0,0 +1,162 @@ +# Generated by Django 5.0.9 on 2024-12-14 17:50 + +import django.db.models.deletion +import modelcluster.fields +import wagtail.contrib.forms.models +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("base", "0001_create_footer"), + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="FormPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "to_address", + models.CharField( + blank=True, + help_text="Optional - form submissions will be emailed to these addresses. Separate multiple addresses by comma.", # noqa: E501 + max_length=255, + validators=[wagtail.contrib.forms.models.validate_to_address], + verbose_name="to address", + ), + ), + ( + "from_address", + models.EmailField( + blank=True, max_length=255, verbose_name="from address" + ), + ), + ( + "subject", + models.CharField( + blank=True, max_length=255, verbose_name="subject" + ), + ), + ("intro", wagtail.fields.RichTextField(blank=True)), + ("thank_you_text", wagtail.fields.RichTextField(blank=True)), + ], + options={ + "abstract": False, + }, + bases=( + wagtail.contrib.forms.models.FormMixin, + "wagtailcore.page", + models.Model, + ), + ), + migrations.CreateModel( + name="FormField", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ( + "clean_name", + models.CharField( + blank=True, + default="", + help_text="Safe name of the form field, the label converted to ascii_snake_case", + max_length=255, + verbose_name="name", + ), + ), + ( + "label", + models.CharField( + help_text="The label of the form field", + max_length=255, + verbose_name="label", + ), + ), + ( + "field_type", + models.CharField( + choices=[ + ("singleline", "Single line text"), + ("multiline", "Multi-line text"), + ("email", "Email"), + ("number", "Number"), + ("url", "URL"), + ("checkbox", "Checkbox"), + ("checkboxes", "Checkboxes"), + ("dropdown", "Drop down"), + ("multiselect", "Multiple select"), + ("radio", "Radio buttons"), + ("date", "Date"), + ("datetime", "Date/time"), + ("hidden", "Hidden field"), + ], + max_length=16, + verbose_name="field type", + ), + ), + ( + "required", + models.BooleanField(default=True, verbose_name="required"), + ), + ( + "choices", + models.TextField( + blank=True, + help_text="Comma or new line separated list of choices. Only applicable in checkboxes, radio and dropdown.", # noqa: E501 + verbose_name="choices", + ), + ), + ( + "default_value", + models.TextField( + blank=True, + help_text="Default value. Comma or new line separated values supported for checkboxes.", + verbose_name="default value", + ), + ), + ( + "help_text", + models.CharField( + blank=True, max_length=255, verbose_name="help text" + ), + ), + ( + "page", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="form_fields", + to="base.formpage", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/app/base/migrations/__init__.py b/app/base/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/base/models.py b/app/base/models.py new file mode 100644 index 0000000..1540386 --- /dev/null +++ b/app/base/models.py @@ -0,0 +1,94 @@ +from django.db import models +from modelcluster.fields import ParentalKey +from wagtail.admin.panels import ( + FieldPanel, + FieldRowPanel, + InlinePanel, + MultiFieldPanel, + PublishingPanel, +) +from wagtail.contrib.forms.models import AbstractEmailForm, AbstractFormField +from wagtail.contrib.forms.panels import FormSubmissionsPanel +from wagtail.contrib.settings.models import BaseGenericSetting, register_setting +from wagtail.fields import RichTextField +from wagtail.models import ( + DraftStateMixin, + PreviewableMixin, + RevisionMixin, + TranslatableMixin, +) +from wagtail.snippets.models import register_snippet + + +@register_setting +class NavigationSettings(BaseGenericSetting): + linkedin_url = models.URLField(verbose_name="LinkedIn URL", blank=True) + github_url = models.URLField(verbose_name="GitHub URL", blank=True) + mastodon_url = models.URLField(verbose_name="Mastodon URL", blank=True) + + panels = [ + MultiFieldPanel( + [ + FieldPanel("linkedin_url"), + FieldPanel("github_url"), + FieldPanel("mastodon_url"), + ], + "Social settings", + ) + ] + + +@register_snippet +class FooterText( + DraftStateMixin, + RevisionMixin, + PreviewableMixin, + TranslatableMixin, + models.Model, +): + body = RichTextField() + + panels = [ + FieldPanel("body"), + PublishingPanel(), + ] + + class Meta(TranslatableMixin.Meta): + verbose_name_plural = "Footer Text" + + def __str__(self): + return "Footer text" + + def get_preview_template(self, request, mode_name): + return "base.html" + + def get_preview_context(self, request, mode_name): + return {"footer_text": self.body} + + +class FormField(AbstractFormField): + page = ParentalKey("FormPage", on_delete=models.CASCADE, related_name="form_fields") + + +class FormPage(AbstractEmailForm): + intro = RichTextField(blank=True) + thank_you_text = RichTextField(blank=True) + + content_panels = AbstractEmailForm.content_panels + [ + FormSubmissionsPanel(), + FieldPanel("intro"), + InlinePanel("form_fields", label="Form fields"), + FieldPanel("thank_you_text"), + MultiFieldPanel( + [ + FieldRowPanel( + [ + FieldPanel("from_address"), + FieldPanel("to_address"), + ] + ), + FieldPanel("subject"), + ], + "Email", + ), + ] diff --git a/app/base/templates/base/blocks/captioned_image_block.html b/app/base/templates/base/blocks/captioned_image_block.html new file mode 100644 index 0000000..453ed66 --- /dev/null +++ b/app/base/templates/base/blocks/captioned_image_block.html @@ -0,0 +1,9 @@ +{% load wagtailimages_tags %} + +
+
+ {% image self.image fill-600x338 loading="lazy" %} +
{{ self.caption }} - {{ self.attribution }}
+
+ +
diff --git a/app/base/templates/base/blocks/embed_block.html b/app/base/templates/base/blocks/embed_block.html new file mode 100644 index 0000000..7e927ba --- /dev/null +++ b/app/base/templates/base/blocks/embed_block.html @@ -0,0 +1,5 @@ +{% load wagtailembeds_tags %} + +
+ {{ self }} +
diff --git a/app/base/templates/base/blocks/heading_block.html b/app/base/templates/base/blocks/heading_block.html new file mode 100644 index 0000000..8405ce5 --- /dev/null +++ b/app/base/templates/base/blocks/heading_block.html @@ -0,0 +1,7 @@ +{% if self.size == 'h2' %} +

{{ self.heading_text }}

+{% elif self.size == 'h3' %} +

{{ self.heading_text }}

+{% elif self.size == 'h4' %} +

{{ self.heading_text }}

+{% endif %} diff --git a/app/base/templates/base/form_page.html b/app/base/templates/base/form_page.html new file mode 100644 index 0000000..d233174 --- /dev/null +++ b/app/base/templates/base/form_page.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% load wagtailcore_tags %} + +{% block body_class %}template-formpage{% endblock %} + +{% block content %} + +
+ +
+

{{ page.title }}

+
{{ page.intro|richtext }}
+
+ +
+ {% csrf_token %} + {{ form.as_div }} + +
+ +
+ +{% endblock content %} diff --git a/app/base/templates/base/form_page_landing.html b/app/base/templates/base/form_page_landing.html new file mode 100644 index 0000000..7d7b85e --- /dev/null +++ b/app/base/templates/base/form_page_landing.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% load wagtailcore_tags %} + +{% block body_class %}template-formpage{% endblock %} + +{% block content %} +
+
+

{{ page.title }}

+
{{ page.thank_you_text|richtext }}
+
+
+{% endblock content %} diff --git a/app/base/templates/base/includes/footer_text.html b/app/base/templates/base/includes/footer_text.html new file mode 100644 index 0000000..a536e26 --- /dev/null +++ b/app/base/templates/base/includes/footer_text.html @@ -0,0 +1,5 @@ +{% load wagtailcore_tags %} + +
+ {{ footer_text|richtext }} +
diff --git a/app/base/templatetags/__init__.py b/app/base/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/base/templatetags/navigation_tags.py b/app/base/templatetags/navigation_tags.py new file mode 100644 index 0000000..44d887c --- /dev/null +++ b/app/base/templatetags/navigation_tags.py @@ -0,0 +1,24 @@ +from django import template +from wagtail.models import Site + +from app.base.models import FooterText + +register = template.Library() + + +@register.inclusion_tag("base/includes/footer_text.html", takes_context=True) +def get_footer_text(context): + footer_text = context.get("footer_text", "") + + if not footer_text: + instance = FooterText.objects.filter(live=True).first() + footer_text = instance.body if instance else "" + + return { + "footer_text": footer_text, + } + + +@register.simple_tag(takes_context=True) +def get_site_root(context): + return Site.find_for_request(context["request"]).root_page diff --git a/app/blog/management/commands/populate_blog.py b/app/blog/management/commands/populate_blog.py index bfaf936..e7309a0 100644 --- a/app/blog/management/commands/populate_blog.py +++ b/app/blog/management/commands/populate_blog.py @@ -128,6 +128,7 @@ def create_blog_index(self, home_page): "

Welcome to our blog! Here you'll find the latest news, " "insights, and stories from our team.

" ), + show_in_menus=True, ) home_page.add_child(instance=blog_index) blog_index.save_revision().publish() diff --git a/app/blog/templates/blog/blog_index_page.html b/app/blog/templates/blog/blog_index_page.html index f208bca..20227cb 100644 --- a/app/blog/templates/blog/blog_index_page.html +++ b/app/blog/templates/blog/blog_index_page.html @@ -5,44 +5,49 @@ {% block body_class %}template-blogindexpage{% endblock %} {% block content %} -
-

{{ page.title }}

-
{{ page.intro|richtext }}
-
- {% for post in blogpages %} +
+

{{ page.title }}

+
{{ page.intro|richtext }}
+
+ +{% for post in blogpages %} + +{% if forloop.first or forloop.counter0|divisibleby:3 %} +{% comment %} Start a new row {% endcomment %} +
+ {% endif %} - {% if forloop.first or forloop.counter0|divisibleby:2 %} - {% comment %} Start a new row {% endcomment %} +
+ {% with post=post.specific %} +
+

{{ post.title }}

+
- {% endif %} - -
- {% with post=post.specific %} -
-

{{ post.title }}

-
-
- {% with post.main_image as main_image %} - {% if main_image %} -
- {% image main_image fill-160x100 %} -
- {% endif %} - {% endwith %} -
-

{{ post.intro }}

- {{ post.body|richtext }} -
-
- {% endwith %} -
- - {% if forloop.last or forloop.counter|divisibleby:2 %} - {% comment %} End the row {% endcomment %} + {% with post.main_image as main_image %} + + {% if main_image %} +
+ {% image main_image fill-160x100 %} +
+ {% endif %} + + {% endwith %} +
+

{{ post.intro }}

+ {% comment %} Don't display the body text here + {{ post.body|richtext }} + {% endcomment %} +
- {% endif %} + {% endwith %} +
+ + {% if forloop.last or forloop.counter|divisibleby:3 %} + {% comment %} End the row {% endcomment %} +
+{% endif %} - {% endfor %} +{% endfor %} {% endblock %} diff --git a/app/blog/templates/blog/blog_page.html b/app/blog/templates/blog/blog_page.html index 9313091..338b5c9 100644 --- a/app/blog/templates/blog/blog_page.html +++ b/app/blog/templates/blog/blog_page.html @@ -6,77 +6,103 @@ {% block content %} -
-
-
-

{{ page.title }}

-

{{ page.date }}

-
+
+ + - -
- {% with authors=page.authors.all %} - {% if authors %} -

Posted by:

- {% for author in authors %} - - {% image author.author_image fill-40x40 as author_image %} - {% if author_image %} - {{ author.name }} - {% else %} - user - {% endif %} - {{ author.name }} - - {% endfor %} - {% endif %} - {% endwith %} -
+ +
+ +
+ Posted by: + {% with authors=page.authors.all %} + + {% if authors %} + + {% for author in authors %} + + {% image author.author_image fill-40x40 as author_image %} + {% if author_image %} + {{ author.name }} + {% else %} + + user + + + + + + {% endif %} + {{ author.name }} + + {% endfor %} + + {% endif %} + + {% endwith %} +
+ +
+ +
+
+ +
+ {{ page.body|richtext }}
-
-
-
-

{{ page.intro }}

- {{ page.body|richtext }} -
- -
- - {% for item in page.gallery_images.all %} - {% if forloop.first or forloop.counter0|divisibleby:3 %} - {% comment %} Start a new row {% endcomment %} -
- {% endif %} -
- {% image item.image fill-600x400 %} -
- {{ item.caption }} -
-
- {% if forloop.last or forloop.counter|divisibleby:3 %} -
- {% endif %} - {% endfor %} + {% for item in page.gallery_images.all %} + + {% if forloop.first or forloop.counter0|divisibleby:4 %} + {% comment %} Start a new row {% endcomment %} +
+ {% endif %} +
+ {% image item.image fill-600x400 %} +
+ {{ item.caption }} +
+
-
+ {% if forloop.last or forloop.counter|divisibleby:4 %} + {% comment %} End row {% endcomment %} + + + {% endif %} + + {% endfor %} + +
{% endblock %} diff --git a/app/home/management/commands/populate_homepage.py b/app/home/management/commands/populate_homepage.py index 29f39c7..e5be3ad 100644 --- a/app/home/management/commands/populate_homepage.py +++ b/app/home/management/commands/populate_homepage.py @@ -37,6 +37,9 @@ def handle(self, *args, **options): available_images = Image.objects.all() selected_image = None image_embed = "" + hero_image = None + hero_text = None + hero_cta = None if available_images.exists(): # Select a random image @@ -47,6 +50,10 @@ def handle(self, *args, **options): f'format="left" id="{selected_image.id}"/>' ) self.stdout.write(f"Selected image: {selected_image.title}") + # Set hero section fields if they are not already set + hero_image = selected_image + hero_text = "Your Hero Section Title" + hero_cta = "Your Hero Section CTA, you need to set a link" else: self.stdout.write( "No images found - content will be created without images" @@ -100,6 +107,12 @@ def handle(self, *args, **options):

Happy building with Wagtail!

""".strip() + if hero_image: + # Update the hero section fields + home_page.image = hero_image + home_page.hero_text = hero_text + home_page.hero_cta = hero_cta + # Update the home page body content home_page.body = sample_content home_page.save() diff --git a/app/home/migrations/0004_customise_home_page.py b/app/home/migrations/0004_customise_home_page.py new file mode 100644 index 0000000..5b1ee9d --- /dev/null +++ b/app/home/migrations/0004_customise_home_page.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.4 on 2024-12-14 17:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0003_homepage_body"), + ("wagtailcore", "0094_alter_page_locale"), + ("wagtailimages", "0027_image_description"), + ] + + operations = [ + migrations.AddField( + model_name="homepage", + name="hero_cta", + field=models.CharField( + blank=True, + help_text="Text to display on Call to Action", + max_length=255, + verbose_name="Hero CTA", + ), + ), + migrations.AddField( + model_name="homepage", + name="hero_cta_link", + field=models.ForeignKey( + blank=True, + help_text="Choose a page to link to for the Call to Action", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailcore.page", + verbose_name="Hero CTA link", + ), + ), + migrations.AddField( + model_name="homepage", + name="hero_text", + field=models.CharField( + blank=True, + help_text="Write an introduction for the site", + max_length=255, + ), + ), + migrations.AddField( + model_name="homepage", + name="image", + field=models.ForeignKey( + blank=True, + help_text="Homepage image", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="wagtailimages.image", + ), + ), + ] diff --git a/app/home/models.py b/app/home/models.py index fe0adbe..7527f15 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -1,11 +1,48 @@ -from wagtail.admin.panels import FieldPanel +from django.db import models +from wagtail.admin.panels import FieldPanel, MultiFieldPanel from wagtail.fields import RichTextField from wagtail.models import Page class HomePage(Page): + image = models.ForeignKey( + "wagtailimages.Image", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + help_text="Homepage image", + ) + hero_text = models.CharField( + blank=True, max_length=255, help_text="Write an introduction for the site" + ) + hero_cta = models.CharField( + blank=True, + verbose_name="Hero CTA", + max_length=255, + help_text="Text to display on Call to Action", + ) + hero_cta_link = models.ForeignKey( + "wagtailcore.Page", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="+", + verbose_name="Hero CTA link", + help_text="Choose a page to link to for the Call to Action", + ) + body = RichTextField(blank=True) content_panels = Page.content_panels + [ + MultiFieldPanel( + [ + FieldPanel("image"), + FieldPanel("hero_text"), + FieldPanel("hero_cta"), + FieldPanel("hero_cta_link"), + ], + heading="Hero section", + ), FieldPanel("body"), ] diff --git a/app/home/templates/home/home_page.html b/app/home/templates/home/home_page.html index 86b072e..7513ec9 100644 --- a/app/home/templates/home/home_page.html +++ b/app/home/templates/home/home_page.html @@ -1,15 +1,24 @@ {% extends "base.html" %} - -{% load wagtailcore_tags %} +{% load wagtailcore_tags wagtailimages_tags %} {% block body_class %}template-homepage{% endblock %} {% block content %} -
- {% if page.body %} - {{ page.body|richtext }} - {% else %} -

No content has been added to this page body yet.

- {% endif %} -
-{% endblock %} + +{% image page.image fill-1920x1080 as hero_image %} + +
+ {{ page.hero_text }} + {% if page.hero_cta_link %} + + {% firstof page.hero_cta page.hero_cta_link.title %} + + {% endif %} +
+ +
+

{{ page.title }}

+ {{ page.body|richtext }} +
+ +{% endblock content %} diff --git a/app/home/tests.py b/app/home/tests.py index c681f59..bacc9bf 100644 --- a/app/home/tests.py +++ b/app/home/tests.py @@ -35,7 +35,6 @@ def test_home_frontend_returns_200(self): """Test that the home page frontend returns 200 OK.""" response = self.client.get("/") self.assertEqual(response.status_code, 200) - self.assertContains(response, "Wagtail Blog Tutorial") self.assertTemplateUsed(response, "home/home_page.html") def test_home_admin_edit_returns_200(self): diff --git a/app/portfolio/__init__.py b/app/portfolio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/portfolio/apps.py b/app/portfolio/apps.py new file mode 100644 index 0000000..390782e --- /dev/null +++ b/app/portfolio/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PortfolioConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.portfolio" diff --git a/app/portfolio/blocks.py b/app/portfolio/blocks.py new file mode 100644 index 0000000..44a1ade --- /dev/null +++ b/app/portfolio/blocks.py @@ -0,0 +1,35 @@ +from wagtail.blocks import ( + CharBlock, + ListBlock, + PageChooserBlock, + RichTextBlock, + StructBlock, +) +from wagtail.images.blocks import ImageBlock + +from app.base.blocks import BaseStreamBlock + + +class CardBlock(StructBlock): + heading = CharBlock() + text = RichTextBlock(features=["bold", "italic", "link"]) + image = ImageBlock(required=False) + + class Meta: + icon = "form" + template = "portfolio/blocks/card_block.html" + + +class FeaturedPostsBlock(StructBlock): + heading = CharBlock() + text = RichTextBlock(features=["bold", "italic", "link"], required=False) + posts = ListBlock(PageChooserBlock(page_type="blog.BlogPage")) + + class Meta: + icon = "folder-open-inverse" + template = "portfolio/blocks/featured_posts_block.html" + + +class PortfolioStreamBlock(BaseStreamBlock): + card = CardBlock(group="Sections") + featured_posts = FeaturedPostsBlock(group="Sections") diff --git a/app/portfolio/migrations/0001_create_portfolio.py b/app/portfolio/migrations/0001_create_portfolio.py new file mode 100644 index 0000000..c7de119 --- /dev/null +++ b/app/portfolio/migrations/0001_create_portfolio.py @@ -0,0 +1,96 @@ +# Generated by Django 5.1.4 on 2024-12-14 18:02 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0094_alter_page_locale"), + ] + + operations = [ + migrations.CreateModel( + name="PortfolioPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ( + "body", + wagtail.fields.StreamField( + [ + ("heading_block", 2), + ("paragraph_block", 3), + ("image_block", 6), + ("embed_block", 7), + ], + blank=True, + block_lookup={ + 0: ( + "wagtail.blocks.CharBlock", + (), + {"form_classname": "title", "required": True}, + ), + 1: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "blank": True, + "choices": [ + ("", "Select a heading size"), + ("h2", "H2"), + ("h3", "H3"), + ("h4", "H4"), + ], + "required": False, + }, + ), + 2: ( + "wagtail.blocks.StructBlock", + [[("heading_text", 0), ("size", 1)]], + {}, + ), + 3: ( + "wagtail.blocks.RichTextBlock", + (), + {"icon": "pilcrow"}, + ), + 4: ("wagtail.images.blocks.ImageBlock", [], {}), + 5: ("wagtail.blocks.CharBlock", (), {"required": False}), + 6: ( + "wagtail.blocks.StructBlock", + [[("image", 4), ("caption", 5), ("attribution", 5)]], + {}, + ), + 7: ( + "wagtail.embeds.blocks.EmbedBlock", + (), + { + "help_text": "Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks", # noqa: E501 + "icon": "media", + }, + ), + }, + help_text="Use this section to list your projects and skills.", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + ] diff --git a/app/portfolio/migrations/0002_add_more_custom_blocks.py b/app/portfolio/migrations/0002_add_more_custom_blocks.py new file mode 100644 index 0000000..5b88121 --- /dev/null +++ b/app/portfolio/migrations/0002_add_more_custom_blocks.py @@ -0,0 +1,99 @@ +# Generated by Django 5.1.4 on 2024-12-14 18:11 + +import wagtail.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("portfolio", "0001_create_portfolio"), + ] + + operations = [ + migrations.AlterField( + model_name="portfoliopage", + name="body", + field=wagtail.fields.StreamField( + [ + ("heading_block", 2), + ("paragraph_block", 3), + ("image_block", 6), + ("embed_block", 7), + ("card", 10), + ("featured_posts", 14), + ], + blank=True, + block_lookup={ + 0: ( + "wagtail.blocks.CharBlock", + (), + {"form_classname": "title", "required": True}, + ), + 1: ( + "wagtail.blocks.ChoiceBlock", + [], + { + "blank": True, + "choices": [ + ("", "Select a heading size"), + ("h2", "H2"), + ("h3", "H3"), + ("h4", "H4"), + ], + "required": False, + }, + ), + 2: ( + "wagtail.blocks.StructBlock", + [[("heading_text", 0), ("size", 1)]], + {}, + ), + 3: ("wagtail.blocks.RichTextBlock", (), {"icon": "pilcrow"}), + 4: ("wagtail.images.blocks.ImageBlock", [], {}), + 5: ("wagtail.blocks.CharBlock", (), {"required": False}), + 6: ( + "wagtail.blocks.StructBlock", + [[("image", 4), ("caption", 5), ("attribution", 5)]], + {}, + ), + 7: ( + "wagtail.embeds.blocks.EmbedBlock", + (), + { + "help_text": "Insert a URL to embed. For example, https://www.youtube.com/watch?v=SGJFWirQ3ks", # noqa: E501 + "icon": "media", + }, + ), + 8: ("wagtail.blocks.CharBlock", (), {}), + 9: ( + "wagtail.blocks.RichTextBlock", + (), + {"features": ["bold", "italic", "link"]}, + ), + 10: ( + "wagtail.blocks.StructBlock", + [[("heading", 8), ("text", 9), ("image", 4)]], + {"group": "Sections"}, + ), + 11: ( + "wagtail.blocks.RichTextBlock", + (), + {"features": ["bold", "italic", "link"], "required": False}, + ), + 12: ( + "wagtail.blocks.PageChooserBlock", + (), + {"page_type": ["blog.BlogPage"]}, + ), + 13: ("wagtail.blocks.ListBlock", (12,), {}), + 14: ( + "wagtail.blocks.StructBlock", + [[("heading", 8), ("text", 11), ("posts", 13)]], + {"group": "Sections"}, + ), + }, + help_text="Use this section to list your projects and skills.", + ), + ), + ] diff --git a/app/portfolio/migrations/__init__.py b/app/portfolio/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/portfolio/models.py b/app/portfolio/models.py new file mode 100644 index 0000000..b846d48 --- /dev/null +++ b/app/portfolio/models.py @@ -0,0 +1,20 @@ +from wagtail.admin.panels import FieldPanel +from wagtail.fields import StreamField +from wagtail.models import Page + +from app.portfolio.blocks import PortfolioStreamBlock + + +class PortfolioPage(Page): + parent_page_types = ["home.HomePage"] + + body = StreamField( + PortfolioStreamBlock(), + blank=True, + use_json_field=True, + help_text="Use this section to list your projects and skills.", + ) + + content_panels = Page.content_panels + [ + FieldPanel("body"), + ] diff --git a/app/portfolio/templates/portfolio/blocks/card_block.html b/app/portfolio/templates/portfolio/blocks/card_block.html new file mode 100644 index 0000000..3f86120 --- /dev/null +++ b/app/portfolio/templates/portfolio/blocks/card_block.html @@ -0,0 +1,8 @@ +{% load wagtailcore_tags wagtailimages_tags %} +
+

{{ self.heading }}

+
{{ self.text|richtext }}
+ {% if self.image %} + {% image self.image width-480 %} + {% endif %} +
diff --git a/app/portfolio/templates/portfolio/blocks/featured_posts_block.html b/app/portfolio/templates/portfolio/blocks/featured_posts_block.html new file mode 100644 index 0000000..ae59369 --- /dev/null +++ b/app/portfolio/templates/portfolio/blocks/featured_posts_block.html @@ -0,0 +1,16 @@ +{% load wagtailcore_tags %} +
+

{{ self.heading }}

+ {% if self.text %} +

{{ self.text|richtext }}

+ {% endif %} + +
+ {% for page in self.posts %} +
+

{{ page.title }}

+

{{ page.specific.date }}

+
+ {% endfor %} +
+
diff --git a/app/portfolio/templates/portfolio/portfolio_page.html b/app/portfolio/templates/portfolio/portfolio_page.html new file mode 100644 index 0000000..e2d2e86 --- /dev/null +++ b/app/portfolio/templates/portfolio/portfolio_page.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% load wagtailcore_tags wagtailimages_tags %} + +{% block body_class %}template-portfolio{% endblock %} + +{% block content %} + +
+

{{ page.title }}

+ {{ page.body }} +
+ +{% endblock %} diff --git a/app/search/templates/search/search.html b/app/search/templates/search/search.html index 476427f..f30ba6b 100644 --- a/app/search/templates/search/search.html +++ b/app/search/templates/search/search.html @@ -6,33 +6,49 @@ {% block title %}Search{% endblock %} {% block content %} -

Search

- -
- - -
- -{% if search_results %} - - -{% if search_results.has_previous %} -Previous -{% endif %} - -{% if search_results.has_next %} -Next -{% endif %} -{% elif search_query %} -No results found -{% endif %} -{% endblock %} + +
+

Search

+
+
+ + +
+
+ + {% if search_results %} + +

You searched{% if search_query %} for “{{ search_query }}”{% endif %}, {{ search_results.paginator.count }} result{{ search_results.paginator.count|pluralize }} found.

+ +
    + {% for result in search_results %} +
  1. + {{ result }} + {% if result.search_description %} + {{ result.search_description }} + {% endif %} +
  2. + {% endfor %} +
+ + {% if search_results.paginator.num_pages > 1 %} +

Page {{ search_results.number }} of {{ search_results.paginator.num_pages }}, showing {{ search_results|length }} result{{ search_results|pluralize }} out of {{ search_results.paginator.count }}

+ {% endif %} + + {% if search_results.has_previous %} + Previous + {% endif %} + + {% if search_results.has_next %} + Next + {% endif %} + + {% elif search_query %} + + No results found + + {% endif %} + + {% endblock %} + +
diff --git a/app/settings/base.py b/app/settings/base.py index c85d76a..e428a8c 100644 --- a/app/settings/base.py +++ b/app/settings/base.py @@ -25,11 +25,14 @@ # Application definition INSTALLED_APPS = [ + "app.base", + "app.blog", "app.home", + "app.portfolio", "app.search", - "app.blog", "wagtail.contrib.forms", "wagtail.contrib.redirects", + "wagtail.contrib.settings", "wagtail.contrib.table_block", "wagtail.embeds", "wagtail.sites", @@ -76,6 +79,7 @@ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "wagtail.contrib.settings.context_processors.settings", ], }, }, diff --git a/app/settings/dev.py b/app/settings/dev.py index 1ca04f2..3ab230b 100644 --- a/app/settings/dev.py +++ b/app/settings/dev.py @@ -9,8 +9,16 @@ # SECURITY WARNING: define the correct hosts in production! ALLOWED_HOSTS = ["*"] +# CONSOLE email backend EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +# MAILHOG email backend. +# Testing email with mailhog, the mailhog container is running on port 1025 +# UNCOMENT THE FOLLOWING LINES TO USE MAILHOG +# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +# EMAIL_HOST = "mailhog" +# EMAIL_PORT = 1025 + # Remove if not required INSTALLED_APPS += [ # noqa F405 "app.style_guide", diff --git a/app/templates/base.html b/app/templates/base.html index 0d550de..14638ff 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,4 +1,4 @@ -{% load static wagtailcore_tags wagtailuserbar blog_tags %} +{% load static wagtailcore_tags wagtailuserbar navigation_tags blog_tags %} @@ -23,6 +23,9 @@ {% endif %} + + + {# Global stylesheets #} @@ -32,31 +35,16 @@ - {% wagtailuserbar %} - {% blog_index_page as blog %} -
- -
+ {% include "includes/header.html" %} -
- {% block content %}{% endblock %} +
+
+ {% block content %}{% endblock %} +
- + {% include "includes/footer.html" %} {# Global javascript #} diff --git a/app/templates/includes/footer.html b/app/templates/includes/footer.html new file mode 100644 index 0000000..0843e08 --- /dev/null +++ b/app/templates/includes/footer.html @@ -0,0 +1,26 @@ +{% load navigation_tags %} + + diff --git a/app/templates/includes/header.html b/app/templates/includes/header.html new file mode 100644 index 0000000..b473c70 --- /dev/null +++ b/app/templates/includes/header.html @@ -0,0 +1,21 @@ +{% load wagtailcore_tags navigation_tags wagtailuserbar %} + +
+
+ + {% get_site_root as site_root %} + +
+ {% wagtailuserbar "top-right" %} +
diff --git a/static_src/js/app.js b/static_src/js/app.js index a4ce6c7..02a556e 100644 --- a/static_src/js/app.js +++ b/static_src/js/app.js @@ -1,20 +1,14 @@ -/** - * This is a simple example. - * It will log a message to the console. - * Remove this content and write your scripts as required. - */ -class ShowMessage { - constructor(times=3) { - this.message = 'Hello from Wagtail starter kit!'; - this.times = times; +class Navigation { + constructor() { + this.menu = document.querySelector('[role="navigation"]'); + this.menuButton = this.menu.querySelector('button:first-of-type'); + this.menuButton.addEventListener('click', this.toggleMenu.bind(this)); + console.log('Navigation initialized'); } - showMessage() { - for (let i = 0; i < this.times; i++) { - console.log(this.message); - } + toggleMenu() { + this.menu.classList.toggle('is-open'); } } -const showMessage = new ShowMessage(); -showMessage.showMessage(); +new Navigation(); diff --git a/static_src/scss/app.scss b/static_src/scss/app.scss index 63ebb17..bfd76e3 100644 --- a/static_src/scss/app.scss +++ b/static_src/scss/app.scss @@ -10,4 +10,7 @@ // Your custom styles goes here -@use 'components/example'; +@use 'components/skip-link'; +@use 'components/hero'; +@use 'components/navigation'; +@use 'components/embed'; diff --git a/static_src/scss/components/embed.scss b/static_src/scss/components/embed.scss new file mode 100644 index 0000000..da1fd3d --- /dev/null +++ b/static_src/scss/components/embed.scss @@ -0,0 +1,22 @@ +@use '../vars.scss' as *; + +[role=video] { + + > div { + position: relative; + padding-bottom: 420px; + height: 0; + overflow: hidden; + max-width: 720px; + max-height: 420px; + } + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + +} diff --git a/static_src/scss/components/example.scss b/static_src/scss/components/example.scss deleted file mode 100644 index eb0c546..0000000 --- a/static_src/scss/components/example.scss +++ /dev/null @@ -1 +0,0 @@ -// example imported/used in app.scss diff --git a/static_src/scss/components/hero.scss b/static_src/scss/components/hero.scss new file mode 100644 index 0000000..19a3966 --- /dev/null +++ b/static_src/scss/components/hero.scss @@ -0,0 +1,37 @@ +@use '../vars.scss' as *; + +// Hero styles +[role=hero] { + display: relative; + min-height: 200px; + background-position: bottom; + background-size: cover; + position: relative; + mark { + display: block; + background: transparent; + text-align: center; + font-size: 2rem; + font-weight: bold; + color: white; + text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5); + } + [role=button] { + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + } + @media screen and (min-width: $sm) { + min-height: 300px; + } + @media screen and (min-width: $md) { + min-height: 400px; + } + @media screen and (min-width: $lg) { + min-height: 500px; + } + @media screen and (min-width: $xl) { + min-height: 600px; + } +} diff --git a/static_src/scss/components/navigation.scss b/static_src/scss/components/navigation.scss new file mode 100644 index 0000000..1ef1935 --- /dev/null +++ b/static_src/scss/components/navigation.scss @@ -0,0 +1,46 @@ +@use '../vars.scss' as *; + +[role=navigation] { + button:first-child { + display: block; + @media screen and (min-width: $md) { + display: none; + } + } + > div { + position: absolute; + z-index: 1; + top: -3px; + left: 100px; + margin-left: -100%; + padding: 20px; + opacity: 0; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 5px; + a[role=button] { + display: block; + margin-bottom: 3px; + } + @media screen and (min-width: $md) { + position: inherit; + top: auto; + left: auto; + margin-left: 0; + padding: 0; + opacity: 1; + background-color: transparent; + border-radius: 0; + a[role=button] { + display: inline-block; + } + } + } + &.is-open { + > div { + margin-left: 0; + opacity: 1; + transition: all 0.3s ease; + } + } + +} diff --git a/static_src/scss/components/skip-link.scss b/static_src/scss/components/skip-link.scss new file mode 100644 index 0000000..1e271ba --- /dev/null +++ b/static_src/scss/components/skip-link.scss @@ -0,0 +1,9 @@ +// Skip link styles +.skip-link { + position: absolute; + top: -30px; +} + +.skip-link:focus-visible { + top: 5px; +} diff --git a/static_src/scss/vars.scss b/static_src/scss/vars.scss new file mode 100644 index 0000000..0db429c --- /dev/null +++ b/static_src/scss/vars.scss @@ -0,0 +1,6 @@ +// Breakpoints +$sm: 576px; +$md: 768px; +$lg: 1024px; +$xl: 1280px; +$xxl: 1536px;