diff --git a/lpld/home/models.py b/lpld/home/models.py index bfa1b0a5..87689de5 100644 --- a/lpld/home/models.py +++ b/lpld/home/models.py @@ -1,12 +1,18 @@ -from django.apps import apps +import dataclasses + from django.db import models from django.utils import html as html_utils from wagtail import fields from wagtail import images as wagtail_images from wagtail.admin import panels +from wagtail.templatetags import wagtailcore_tags from lpld.core import models as core_models +from lpld.templates.atoms.heading import heading +from lpld.templates.molecules import section +from lpld.templates.organisms import prose +from lpld.templates.organisms.teaser_grid import teaser_grid class HomePage(core_models.BasePage): @@ -31,14 +37,35 @@ class HomePage(core_models.BasePage): def get_context(self, request): context = super().get_context(request) - ProjectPage = apps.get_model("projects", "ProjectPage") - context["projects"] = ProjectPage.objects.all() + extra_context = { + "title": heading.Heading(text=self.title), + "introduction": prose.Prose( + children=wagtailcore_tags.richtext(self.introduction), + ), + "profile_image": self.profile_image, + "projects": self.get_projects_section(), + } - return context + return {**context, **extra_context} - def get_meta_description(self): + def get_meta_description(self) -> str: return self.search_description or self.get_introduction_without_tags() or "" - def get_introduction_without_tags(self): + def get_introduction_without_tags(self) -> str: """Return introduction but without the HTMl tags.""" return html_utils.strip_tags(self.introduction) + + def get_projects_section(self) -> section.Section: + return section.Section( + html_id="projects", + html_class="mt-16 lg:mt-32 pt-16 lg:mt-32", + children=[ + heading.Heading( + text="These are things I have build before", + level=2, + size="md", + extra_class="max-w-lg lg:max-w-2xl", + ), + teaser_grid.TeaserGrid.from_project_pages() + ] + ) diff --git a/lpld/settings.py b/lpld/settings.py index 9fe7e1e5..52784b40 100644 --- a/lpld/settings.py +++ b/lpld/settings.py @@ -42,10 +42,16 @@ if DEBUG: # The internal ips settings is needed to activate the debug toolbar. - INTERNAL_IPS = [ + # https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#configure-internal-ips + # We also need to find the internal IPs when this is running in a container. + import socket # only if you haven't already imported this + hostname, _, ips = socket.gethostbyname_ex(socket.gethostname()) + docker_ips = [ip[: ip.rfind(".")] + ".1" for ip in ips] + local_ips = ["127.0.0.1", "10.0.2.2"] + custom_ips = [ ip.strip() for ip in os.environ.get("INTERNAL_IPS", "").split(",") if ip ] - + INTERNAL_IPS = [*docker_ips, *local_ips, *custom_ips] # Application definition @@ -59,6 +65,7 @@ "lpld.navigation", "lpld.projects", "lpld.technologies", + "lpld.templex", "lpld.utils", "django.contrib.admin", "django.contrib.auth", diff --git a/lpld/templates/__init__.py b/lpld/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/atoms/__init__.py b/lpld/templates/atoms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/atoms/heading/__init__.py b/lpld/templates/atoms/heading/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/atoms/heading/heading.html b/lpld/templates/atoms/heading/heading.html index 6321afd5..55174d04 100644 --- a/lpld/templates/atoms/heading/heading.html +++ b/lpld/templates/atoms/heading/heading.html @@ -1,4 +1,4 @@ -{% load slippers %} +{% load slippers templex %} {% var level=level|default:"1" %} {% var size=size|default:"lg" %} {% spaceless %} @@ -17,6 +17,7 @@ {{ extra_class }} " > - {{ children }} + {{ text }} + {% templex children %} {% endspaceless %} diff --git a/lpld/templates/atoms/heading/heading.py b/lpld/templates/atoms/heading/heading.py new file mode 100644 index 00000000..5f2ff9e0 --- /dev/null +++ b/lpld/templates/atoms/heading/heading.py @@ -0,0 +1,16 @@ +import dataclasses +from typing import Union, Iterable + +from lpld import templex + + +@dataclasses.dataclass +class Heading(templex.Templex): + template = "atoms/heading/heading.html" + + text: str + level: int = 1 + size: str = "" + extra_class: str = "" + + diff --git a/lpld/templates/base.html b/lpld/templates/base.html index d4688381..0762a3ae 100644 --- a/lpld/templates/base.html +++ b/lpld/templates/base.html @@ -31,7 +31,9 @@ {% block body %} - {{ pattern_library_rendered_pattern }} +
+ {{ pattern_library_rendered_pattern }} +
{% endblock body %} {{ sentry_settings|json_script:"sentry-settings" }} diff --git a/lpld/templates/molecules/__init__.py b/lpld/templates/molecules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/molecules/project-card/project-card.html b/lpld/templates/molecules/project-card/project-card.html deleted file mode 100644 index 963ed35e..00000000 --- a/lpld/templates/molecules/project-card/project-card.html +++ /dev/null @@ -1,45 +0,0 @@ -{% load l10n wagtailcore_tags wagtailimages_tags %} - - - {% if project.video or project.image %} -
- {% if project.video %} - - {% elif project.image %} - - {% image project.image width-96 as project_image_fallback %} - {% image_url project.image "width-96|format-webp" as project_image_96 %} - {% image_url project.image "width-128|format-webp" as project_image_128 %} - {% image_url project.image "width-192|format-webp" as project_image_192 %} - {% image_url project.image "width-256|format-webp" as project_image_256 %} - {% image_url project.image "width-384|format-webp" as project_image_384 %} - - - - Screenshot of {{ project.title }} - - {% endif %} -
- {% endif %} - -
- -

{{ project.title }}

-
-

{{ project.introduction }}

-
-
diff --git a/lpld/templates/molecules/project-card/project-card.yaml b/lpld/templates/molecules/project-card/project-card.yaml deleted file mode 100644 index 236ee86f..00000000 --- a/lpld/templates/molecules/project-card/project-card.yaml +++ /dev/null @@ -1,33 +0,0 @@ -context: - project: - image: True - image_shadow: True - title: "Remember Me" - introduction: "Developed for fun in my spare time." - -tags: - image: - "project.image width-96 as project_image_fallback": - target_var: "project_image_fallback" - raw: - url: "https://via.placeholder.com/96x120/" - width: 96 - height: 120 - image_url: - 'project.image "width-96|format-webp" as project_image_96': - target_var: "project_image_96" - raw: "https://via.placeholder.com/96x120/" - 'project.image "width-128|format-webp" as project_image_128': - target_var: "project_image_128" - raw: "https://via.placeholder.com/128x160/" - 'project.image "width-192|format-webp" as project_image_192': - target_var: "project_image_192" - raw: "https://via.placeholder.com/192x240/" - 'project.image "width-256|format-webp" as project_image_256': - target_var: "project_image_256" - raw: "https://via.placeholder.com/256x320/" - 'project.image "width-384|format-webp" as project_image_384': - target_var: "project_image_384" - raw: "https://via.placeholder.com/384x480/" - - diff --git a/lpld/templates/molecules/section/__init__.py b/lpld/templates/molecules/section/__init__.py new file mode 100644 index 00000000..6038df4b --- /dev/null +++ b/lpld/templates/molecules/section/__init__.py @@ -0,0 +1,13 @@ +import dataclasses + +from lpld import templex + + +@dataclasses.dataclass(frozen=True) +class Section(templex.Templex): + template="molecules/section/section.html" + + children: templex.TemplexRenderable + html_id: str = "" + html_class: str = "" + diff --git a/lpld/templates/molecules/section/section.html b/lpld/templates/molecules/section/section.html new file mode 100644 index 00000000..7eb7fb75 --- /dev/null +++ b/lpld/templates/molecules/section/section.html @@ -0,0 +1,8 @@ +{% load templex %} + +
+ {% templex children %} +
diff --git a/lpld/templates/molecules/teaser/__init__.py b/lpld/templates/molecules/teaser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/molecules/teaser/teaser.html b/lpld/templates/molecules/teaser/teaser.html new file mode 100644 index 00000000..b26dd020 --- /dev/null +++ b/lpld/templates/molecules/teaser/teaser.html @@ -0,0 +1,45 @@ +{% load l10n wagtailcore_tags wagtailimages_tags %} + + + {% if video or image %} +
+ {% if video %} + + {% elif image %} + + {% image image width-96 as image_fallback %} + {% image_url image "width-96|format-webp" as image_96 %} + {% image_url image "width-128|format-webp" as image_128 %} + {% image_url image "width-192|format-webp" as image_192 %} + {% image_url image "width-256|format-webp" as image_256 %} + {% image_url image "width-384|format-webp" as image_384 %} + + + + Screenshot of {{ title }} + + {% endif %} +
+ {% endif %} + +
+ +

{{ heading }}

+
+

{{ introduction }}

+
+
diff --git a/lpld/templates/molecules/teaser/teaser.py b/lpld/templates/molecules/teaser/teaser.py new file mode 100644 index 00000000..25d0de4c --- /dev/null +++ b/lpld/templates/molecules/teaser/teaser.py @@ -0,0 +1,33 @@ +import dataclasses +from typing import Optional, TYPE_CHECKING + +from wagtail.images import models as images_models +from wagtailmedia import models as media_models + +from lpld import templex + +if TYPE_CHECKING: + from lpld.projects import models as projects_models + + +@dataclasses.dataclass(frozen=True) +class Teaser(templex.Templex): + template="molecules/teaser/teaser.html" + + heading: str + introduction: str + href: str + image: Optional[images_models.AbstractImage] + image_shadow: bool + video: Optional[media_models.AbstractMedia] + + @classmethod + def from_project_page(cls, project_page: "projects_models.ProjectPage") -> "Teaser": + return cls( + heading=project_page.title, + introduction=project_page.introduction, + href=project_page.get_url(), + image=project_page.image, + image_shadow=project_page.image_shadow, + video=project_page.video, + ) diff --git a/lpld/templates/molecules/teaser/teaser.yaml b/lpld/templates/molecules/teaser/teaser.yaml new file mode 100644 index 00000000..f67c9f53 --- /dev/null +++ b/lpld/templates/molecules/teaser/teaser.yaml @@ -0,0 +1,33 @@ +context: + image: True + image_shadow: True + heading: "Remember Me" + introduction: "Developed for fun in my spare time." + href: "https://example.com" + +tags: + image: + "image width-96 as image_fallback": + target_var: "image_fallback" + raw: + url: "https://via.placeholder.com/96x120/" + width: 96 + height: 120 + image_url: + 'image "width-96|format-webp" as image_96': + target_var: "image_96" + raw: "https://via.placeholder.com/96x120/" + 'image "width-128|format-webp" as image_128': + target_var: "image_128" + raw: "https://via.placeholder.com/128x160/" + 'image "width-192|format-webp" as image_192': + target_var: "image_192" + raw: "https://via.placeholder.com/192x240/" + 'image "width-256|format-webp" as image_256': + target_var: "image_256" + raw: "https://via.placeholder.com/256x320/" + 'image "width-384|format-webp" as image_384': + target_var: "image_384" + raw: "https://via.placeholder.com/384x480/" + + diff --git a/lpld/templates/organisms/__init__.py b/lpld/templates/organisms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/organisms/prose/__init__.py b/lpld/templates/organisms/prose/__init__.py new file mode 100644 index 00000000..aa1cdf0e --- /dev/null +++ b/lpld/templates/organisms/prose/__init__.py @@ -0,0 +1,10 @@ +import dataclasses + +from lpld import templex + + +@dataclasses.dataclass(frozen=True) +class Prose(templex.Templex): + template = "organisms/prose/prose.html" + + children: templex.TemplexRenderable diff --git a/lpld/templates/organisms/prose/prose.html b/lpld/templates/organisms/prose/prose.html index 43f21816..ffae0dc0 100644 --- a/lpld/templates/organisms/prose/prose.html +++ b/lpld/templates/organisms/prose/prose.html @@ -1,3 +1,4 @@ +{% load templex %}
- {{ children }} + {% templex children %}
- diff --git a/lpld/templates/organisms/teaser_grid/__init__.py b/lpld/templates/organisms/teaser_grid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templates/organisms/teaser_grid/teaser-grid.html b/lpld/templates/organisms/teaser_grid/teaser-grid.html new file mode 100644 index 00000000..5b73dcec --- /dev/null +++ b/lpld/templates/organisms/teaser_grid/teaser-grid.html @@ -0,0 +1,7 @@ + diff --git a/lpld/templates/organisms/teaser_grid/teaser_grid.py b/lpld/templates/organisms/teaser_grid/teaser_grid.py new file mode 100644 index 00000000..943a91ed --- /dev/null +++ b/lpld/templates/organisms/teaser_grid/teaser_grid.py @@ -0,0 +1,23 @@ +import dataclasses + +from django.apps import apps + +from lpld.templates.molecules.teaser import teaser +from lpld import templex + + +@dataclasses.dataclass(frozen=True) +class TeaserGrid(templex.Templex): + template="organisms/teaser_grid/teaser-grid.html" + teasers: list[teaser.Teaser] + + @classmethod + def from_project_pages(cls) -> "TeaserGrid": + ProjectPage = apps.get_model("projects", "ProjectPage") + + return cls( + teasers=[ + teaser.Teaser.from_project_page(project_page) + for project_page in ProjectPage.objects.live().public() + ] + ) diff --git a/lpld/templates/pages/home/home.html b/lpld/templates/pages/home/home.html index 013439c9..327a5715 100644 --- a/lpld/templates/pages/home/home.html +++ b/lpld/templates/pages/home/home.html @@ -1,16 +1,16 @@ {% extends "base-page.html" %} -{% load slippers static wagtailcore_tags wagtailimages_tags %} +{% load slippers static templex wagtailcore_tags wagtailimages_tags %} {% block content %}
-
- {% #heading level="1" extra_class="inline-block" %}{{ page.title }}{% /heading %} +
+ {% templex title extra_class="inline-block" %}
{% image page.profile_image fill-96x96 as profile_fallback %} {% image_url page.profile_image "fill-96x96|format-webp" as profile_96 %} - {% image_url page.profile_image "fill-128x128|format-webp" as profile_128 %} + {% image_url page.profile_image "fill-127x128|format-webp" as profile_128 %} {% image_url page.profile_image "fill-192x192|format-webp" as profile_192 %} {% image_url page.profile_image "fill-256x256|format-webp" as profile_256 %} {% image_url page.profile_image "fill-384x384|format-webp" as profile_384 %} @@ -35,20 +35,9 @@ max-w-sm md:max-w-md lg:max-w-lg mt-8 lg:mt-16 "> - {% #prose large=True %} - {{ page.introduction|richtext }} - {% /prose %} + {% templex introduction large=True %}
-
- {% #heading level="2" size="md" extra_class="max-w-lg lg:max-w-2xl" %}These are things I have built before{% /heading %} - -
+ {% templex projects %} {% endblock content %} diff --git a/lpld/templatetags/lpldutils.py b/lpld/templatetags/lpldutils.py index d4df820c..18122723 100644 --- a/lpld/templatetags/lpldutils.py +++ b/lpld/templatetags/lpldutils.py @@ -23,3 +23,10 @@ def sentry_meta() -> str: """ meta = sentry_sdk.Hub.current.trace_propagation_meta() return safestring.mark_safe(meta) + + +@register.simple_tag(takes_context=False) +def include_templex(templex): + if not hasattr(templex, "render"): + raise Exception(f"Object {templex} is not a templex object.") + return templex.render() diff --git a/lpld/templex/__init__.py b/lpld/templex/__init__.py new file mode 100644 index 00000000..5ccdba19 --- /dev/null +++ b/lpld/templex/__init__.py @@ -0,0 +1 @@ +from .templex import Templex, TemplexRenderable diff --git a/lpld/templex/templatetags/__init__.py b/lpld/templex/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lpld/templex/templatetags/templex.py b/lpld/templex/templatetags/templex.py new file mode 100644 index 00000000..985dece4 --- /dev/null +++ b/lpld/templex/templatetags/templex.py @@ -0,0 +1,32 @@ +from typing import Iterable +from django import template +from django.utils import safestring + +from lpld import templex as templex_module + +register = template.Library() + + +@register.simple_tag(takes_context=False) +def templex(obj: templex_module.TemplexRenderable, **kwargs) -> safestring.SafeString: + """ + Render a templex object. + + Iterables of templex objects are also supported. In that case, the templexes + are rendered and concatenated. + + You can also pass safe strings trough this tag. They will be returned as is. + + """ + + if isinstance(obj, safestring.SafeString): + return obj + elif isinstance(obj, templex_module.Templex): + return obj.render(**kwargs) + elif isinstance(obj, Iterable): + renders = [ + item.render(**kwargs) + for item in obj + if isinstance(item, templex_module.Templex) + ] + return safestring.mark_safe("".join(renders)) diff --git a/lpld/templex/templex.py b/lpld/templex/templex.py new file mode 100644 index 00000000..a67f45f0 --- /dev/null +++ b/lpld/templex/templex.py @@ -0,0 +1,55 @@ +from typing import TYPE_CHECKING, Union, Iterable +import abc +import dataclasses + +from django.template import loader + +if TYPE_CHECKING: + from django.utils import safestring + + +class Templex(abc.ABC): + template: str + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not hasattr(cls, 'template'): + raise TypeError( + f"Templex subclass '{cls.__name__}' must set 'template' attribute." + ) + + def get_template(self) -> str: + return self.template + + def get_context_data(self) -> dict: + """ + Return a dictionary of data to be passed to the template. + + By default, this class attempts to convert the object itself into a dictionary. + """ + if dataclasses.is_dataclass(self): + # Shallow copy to avoid the resolution of nested templexes. We need the + # nested templexes to stay templexes so that they keep their render method + # and instead of being turned into plain dicts. + return dict( + (field.name, getattr(self, field.name)) + for field in dataclasses.fields(self) + ) + else: + return self.__dict__.copy() + + def render(self, **kwargs: dict) -> "safestring.SafeString": + """ + Render the templex by passing its data into the template. + + The context can be extended by passing keyword arguments to this method. + + """ + data_dict = self.get_context_data() + if kwargs: + data_dict.update(kwargs) + templex_template = loader.get_template(self.get_template()) + return templex_template.render(data_dict) + + +TemplexRenderable = Union[str, Templex, Iterable[Templex]] diff --git a/tests/test_home/test_models.py b/tests/test_home/test_models.py index 72c67180..78aadea6 100644 --- a/tests/test_home/test_models.py +++ b/tests/test_home/test_models.py @@ -3,15 +3,41 @@ import pytest from pytest_django import asserts -from lpld.home import factories +from lpld.home import factories as home_factories +from lpld.projects import factories as projects_factories @pytest.mark.django_db class TestHomePage: def test_page_loads(self, client): - home_page = factories.HomePage() + home_page = home_factories.HomePage() response = client.get(path=home_page.get_url()) assert response.status_code == http.HTTPStatus.OK asserts.assertContains(response, home_page.title) + + def test_get_projects(self): + home_page = home_factories.HomePage() + project_page_1 = projects_factories.ProjectPage( + parent=home_page, + title="Project 1", + introduction="Introduction 1", + ) + project_page_2 = projects_factories.ProjectPage( + parent=home_page, + title="Project 2", + introduction="Introduction 2", + ) + + teaser_grid = home_page.projects_teaser_grid + + assert len(teaser_grid.teasers) == 2 + teaser_1 = teaser_grid.teasers[0] + assert teaser_1.heading == project_page_1.title + assert teaser_1.introduction == project_page_1.introduction + assert teaser_1.href == project_page_1.get_url() + teaser_2 = teaser_grid.teasers[1] + assert teaser_2.heading == project_page_2.title + assert teaser_2.introduction == project_page_2.introduction + assert teaser_2.href == project_page_2.get_url()