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 %}
-
- {% 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 %}
+
+ {% 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 %}
-
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 @@
+
+ {% for teaser in teasers %}
+ -
+ {{ teaser.render }}
+
+ {% endfor %}
+
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" %}
-
- {% #heading level="2" size="md" extra_class="max-w-lg lg:max-w-2xl" %}These are things I have built before{% /heading %}
-
- {% for project in projects %}
- -
- {% include "molecules/project-card/project-card.html" with project=project %}
-
- {% endfor %}
-
-
+ {% 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()