Merge pull request 'Implement category taxonomy and navigation (Issue #35)' (#36) from feature/category-navigation-system into main
Reviewed-on: #36 Reviewed-by: codex_a <codex_a@linteldigital.com>
This commit was merged in pull request #36.
This commit is contained in:
@@ -75,12 +75,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
|
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
|
||||||
|
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: docker build -t "$CI_IMAGE" .
|
run: docker build -t "$CI_IMAGE" .
|
||||||
|
|
||||||
|
- name: Ensure Playwright Chromium cache
|
||||||
|
run: |
|
||||||
|
docker volume create "$PLAYWRIGHT_CACHE_VOLUME" >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright" \
|
||||||
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
"$CI_IMAGE" \
|
||||||
|
python -m playwright install chromium
|
||||||
|
|
||||||
- name: Start PostgreSQL
|
- name: Start PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name pr-e2e-postgres \
|
docker run -d --name pr-e2e-postgres \
|
||||||
@@ -100,14 +110,14 @@ jobs:
|
|||||||
- name: Start app with seeded content
|
- name: Start app with seeded content
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
|
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
|
||||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
|
||||||
-e SECRET_KEY=ci-secret-key \
|
-e SECRET_KEY=ci-secret-key \
|
||||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||||
-e CONSENT_POLICY_VERSION=1 \
|
-e CONSENT_POLICY_VERSION=1 \
|
||||||
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
||||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||||
-e NEWSLETTER_PROVIDER=buttondown \
|
-e NEWSLETTER_PROVIDER=buttondown \
|
||||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
"$CI_IMAGE" \
|
"$CI_IMAGE" \
|
||||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
@@ -139,10 +149,19 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
|
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
|
||||||
|
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build
|
- name: Build
|
||||||
run: docker build -t "$CI_IMAGE" .
|
run: docker build -t "$CI_IMAGE" .
|
||||||
|
- name: Ensure Playwright Chromium cache
|
||||||
|
run: |
|
||||||
|
docker volume create "$PLAYWRIGHT_CACHE_VOLUME" >/dev/null
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright" \
|
||||||
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
|
"$CI_IMAGE" \
|
||||||
|
python -m playwright install chromium
|
||||||
- name: Start PostgreSQL
|
- name: Start PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name nightly-postgres \
|
docker run -d --name nightly-postgres \
|
||||||
@@ -161,14 +180,14 @@ jobs:
|
|||||||
- name: Start dev server with seeded content
|
- name: Start dev server with seeded content
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
docker run -d --name nightly-e2e --network container:nightly-postgres \
|
||||||
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
|
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
|
||||||
-e SECRET_KEY=ci-secret-key \
|
-e SECRET_KEY=ci-secret-key \
|
||||||
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
|
||||||
-e CONSENT_POLICY_VERSION=1 \
|
-e CONSENT_POLICY_VERSION=1 \
|
||||||
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
|
||||||
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
|
||||||
-e NEWSLETTER_PROVIDER=buttondown \
|
-e NEWSLETTER_PROVIDER=buttondown \
|
||||||
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
|
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||||
"$CI_IMAGE" \
|
"$CI_IMAGE" \
|
||||||
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
sh -lc "python manage.py migrate --noinput && python manage.py seed_e2e_content && python manage.py runserver 0.0.0.0:8000"
|
||||||
for i in $(seq 1 40); do
|
for i in $(seq 1 40); do
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from django.contrib.syndication.views import Feed
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from apps.blog.models import ArticlePage
|
from apps.blog.models import ArticlePage, Category
|
||||||
|
|
||||||
|
|
||||||
class AllArticlesFeed(Feed):
|
class AllArticlesFeed(Feed):
|
||||||
@@ -48,3 +48,15 @@ class TagArticlesFeed(AllArticlesFeed):
|
|||||||
|
|
||||||
def items(self, obj):
|
def items(self, obj):
|
||||||
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]
|
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryArticlesFeed(AllArticlesFeed):
|
||||||
|
def get_object(self, request, category_slug: str):
|
||||||
|
self.request = request
|
||||||
|
return get_object_or_404(Category, slug=category_slug)
|
||||||
|
|
||||||
|
def title(self, obj):
|
||||||
|
return f"No Hype AI — {obj.name}"
|
||||||
|
|
||||||
|
def items(self, obj):
|
||||||
|
return ArticlePage.objects.live().filter(category=obj).order_by("-first_published_at")[:20]
|
||||||
|
|||||||
86
apps/blog/migrations/0002_category_articlepage_category.py
Normal file
86
apps/blog/migrations/0002_category_articlepage_category.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Generated by Django 5.2.11 on 2026-03-03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_default_category(apps, schema_editor):
|
||||||
|
Category = apps.get_model("blog", "Category")
|
||||||
|
Category.objects.get_or_create(
|
||||||
|
slug="general",
|
||||||
|
defaults={
|
||||||
|
"name": "General",
|
||||||
|
"description": "General articles",
|
||||||
|
"colour": "neutral",
|
||||||
|
"sort_order": 0,
|
||||||
|
"show_in_nav": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_default_category_to_articles(apps, schema_editor):
|
||||||
|
Category = apps.get_model("blog", "Category")
|
||||||
|
ArticlePage = apps.get_model("blog", "ArticlePage")
|
||||||
|
default_category = Category.objects.get(slug="general")
|
||||||
|
ArticlePage.objects.filter(category__isnull=True).update(category=default_category)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("blog", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Category",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("name", models.CharField(max_length=100, unique=True)),
|
||||||
|
("slug", models.SlugField(unique=True)),
|
||||||
|
("description", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"hero_image",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to="wagtailimages.image",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"colour",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")],
|
||||||
|
default="neutral",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("sort_order", models.IntegerField(default=0)),
|
||||||
|
("show_in_nav", models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={"ordering": ["sort_order", "name"]},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="articlepage",
|
||||||
|
name="category",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="+",
|
||||||
|
to="blog.category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_default_category, migrations.RunPython.noop),
|
||||||
|
migrations.RunPython(assign_default_category_to_articles, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="articlepage",
|
||||||
|
name="category",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name="+",
|
||||||
|
to="blog.category",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -7,10 +7,12 @@ from typing import Any
|
|||||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
from django.db.models import CASCADE, PROTECT, SET_NULL, Prefetch
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from modelcluster.contrib.taggit import ClusterTaggableManager
|
from modelcluster.contrib.taggit import ClusterTaggableManager
|
||||||
from modelcluster.fields import ParentalKey
|
from modelcluster.fields import ParentalKey
|
||||||
from taggit.models import Tag, TaggedItemBase
|
from taggit.models import Tag, TaggedItemBase
|
||||||
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
||||||
|
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
|
||||||
from wagtail.fields import RichTextField, StreamField
|
from wagtail.fields import RichTextField, StreamField
|
||||||
from wagtail.models import Page
|
from wagtail.models import Page
|
||||||
from wagtailseo.models import SeoMixin
|
from wagtailseo.models import SeoMixin
|
||||||
@@ -34,7 +36,7 @@ class HomePage(Page):
|
|||||||
articles_qs = (
|
articles_qs = (
|
||||||
ArticlePage.objects.live()
|
ArticlePage.objects.live()
|
||||||
.public()
|
.public()
|
||||||
.select_related("author")
|
.select_related("author", "category")
|
||||||
.prefetch_related("tags__metadata")
|
.prefetch_related("tags__metadata")
|
||||||
.order_by("-first_published_at")
|
.order_by("-first_published_at")
|
||||||
)
|
)
|
||||||
@@ -47,10 +49,11 @@ class HomePage(Page):
|
|||||||
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
|
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
|
||||||
).distinct().order_by("name")
|
).distinct().order_by("name")
|
||||||
)
|
)
|
||||||
|
ctx["available_categories"] = Category.objects.filter(show_in_nav=True).order_by("sort_order", "name")
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
class ArticleIndexPage(Page):
|
class ArticleIndexPage(RoutablePageMixin, Page):
|
||||||
parent_page_types = ["blog.HomePage"]
|
parent_page_types = ["blog.HomePage"]
|
||||||
subpage_types = ["blog.ArticlePage"]
|
subpage_types = ["blog.ArticlePage"]
|
||||||
ARTICLES_PER_PAGE = 12
|
ARTICLES_PER_PAGE = 12
|
||||||
@@ -59,15 +62,24 @@ class ArticleIndexPage(Page):
|
|||||||
return (
|
return (
|
||||||
ArticlePage.objects.child_of(self)
|
ArticlePage.objects.child_of(self)
|
||||||
.live()
|
.live()
|
||||||
.select_related("author")
|
.select_related("author", "category")
|
||||||
.prefetch_related("tags__metadata")
|
.prefetch_related("tags__metadata")
|
||||||
.order_by("-first_published_at")
|
.order_by("-first_published_at")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context(self, request, *args, **kwargs):
|
def get_category_url(self, category):
|
||||||
ctx = super().get_context(request, *args, **kwargs)
|
return f"{self.url}category/{category.slug}/"
|
||||||
|
|
||||||
|
def get_listing_context(self, request, active_category=None):
|
||||||
tag_slug = request.GET.get("tag")
|
tag_slug = request.GET.get("tag")
|
||||||
articles = self.get_articles()
|
articles = self.get_articles()
|
||||||
|
available_categories = Category.objects.order_by("sort_order", "name")
|
||||||
|
category_links = [
|
||||||
|
{"category": category, "url": self.get_category_url(category)}
|
||||||
|
for category in available_categories
|
||||||
|
]
|
||||||
|
if active_category:
|
||||||
|
articles = articles.filter(category=active_category)
|
||||||
available_tags = (
|
available_tags = (
|
||||||
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
|
||||||
)
|
)
|
||||||
@@ -81,10 +93,25 @@ class ArticleIndexPage(Page):
|
|||||||
page_obj = paginator.page(1)
|
page_obj = paginator.page(1)
|
||||||
except EmptyPage:
|
except EmptyPage:
|
||||||
page_obj = paginator.page(paginator.num_pages)
|
page_obj = paginator.page(paginator.num_pages)
|
||||||
ctx["articles"] = page_obj
|
return {
|
||||||
ctx["paginator"] = paginator
|
"articles": page_obj,
|
||||||
ctx["active_tag"] = tag_slug
|
"paginator": paginator,
|
||||||
ctx["available_tags"] = available_tags
|
"active_tag": tag_slug,
|
||||||
|
"available_tags": available_tags,
|
||||||
|
"available_categories": available_categories,
|
||||||
|
"category_links": category_links,
|
||||||
|
"active_category": active_category,
|
||||||
|
"active_category_url": self.get_category_url(active_category) if active_category else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
@route(r"^category/(?P<category_slug>[-\w]+)/$")
|
||||||
|
def category_listing(self, request, category_slug):
|
||||||
|
category = get_object_or_404(Category, slug=category_slug)
|
||||||
|
return self.render(request, context_overrides=self.get_listing_context(request, active_category=category))
|
||||||
|
|
||||||
|
def get_context(self, request, *args, **kwargs):
|
||||||
|
ctx = super().get_context(request, *args, **kwargs)
|
||||||
|
ctx.update(self.get_listing_context(request))
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -92,6 +119,36 @@ class ArticleTag(TaggedItemBase):
|
|||||||
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
content_object = ParentalKey("blog.ArticlePage", related_name="tagged_items", on_delete=CASCADE)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(models.Model):
|
||||||
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
hero_image = models.ForeignKey(
|
||||||
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
|
)
|
||||||
|
colour = models.CharField(max_length=20, choices=COLOUR_CHOICES, default="neutral")
|
||||||
|
sort_order = models.IntegerField(default=0)
|
||||||
|
show_in_nav = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
panels = [
|
||||||
|
FieldPanel("name"),
|
||||||
|
FieldPanel("slug"),
|
||||||
|
FieldPanel("description"),
|
||||||
|
FieldPanel("hero_image"),
|
||||||
|
FieldPanel("colour"),
|
||||||
|
FieldPanel("sort_order"),
|
||||||
|
FieldPanel("show_in_nav"),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["sort_order", "name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class TagMetadata(models.Model):
|
class TagMetadata(models.Model):
|
||||||
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||||
|
|
||||||
@@ -124,6 +181,7 @@ class TagMetadata(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class ArticlePage(SeoMixin, Page):
|
class ArticlePage(SeoMixin, Page):
|
||||||
|
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="+")
|
||||||
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
||||||
hero_image = models.ForeignKey(
|
hero_image = models.ForeignKey(
|
||||||
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||||
@@ -138,6 +196,7 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
subpage_types: list[str] = []
|
subpage_types: list[str] = []
|
||||||
|
|
||||||
content_panels = Page.content_panels + [
|
content_panels = Page.content_panels + [
|
||||||
|
FieldPanel("category"),
|
||||||
FieldPanel("author"),
|
FieldPanel("author"),
|
||||||
FieldPanel("hero_image"),
|
FieldPanel("hero_image"),
|
||||||
FieldPanel("summary"),
|
FieldPanel("summary"),
|
||||||
@@ -151,6 +210,11 @@ class ArticlePage(SeoMixin, Page):
|
|||||||
search_fields = Page.search_fields
|
search_fields = Page.search_fields
|
||||||
|
|
||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
if not self.category_id:
|
||||||
|
self.category, _ = Category.objects.get_or_create(
|
||||||
|
slug="general",
|
||||||
|
defaults={"name": "General", "description": "General articles", "colour": "neutral"},
|
||||||
|
)
|
||||||
self.read_time_mins = self._compute_read_time()
|
self.read_time_mins = self._compute_read_time()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from apps.blog.feeds import AllArticlesFeed
|
from apps.blog.feeds import AllArticlesFeed
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -16,3 +18,32 @@ def test_all_feed_methods(article_page):
|
|||||||
def test_tag_feed_not_found(client):
|
def test_tag_feed_not_found(client):
|
||||||
resp = client.get("/feed/tag/does-not-exist/")
|
resp = client.get("/feed/tag/does-not-exist/")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_feed_endpoint(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
category = Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Feed Review",
|
||||||
|
slug="feed-review",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>Body</p>")],
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/feed/category/reviews/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp["Content-Type"].startswith("application/rss+xml")
|
||||||
|
assert "Feed Review" in resp.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_feed_not_found(client):
|
||||||
|
resp = client.get("/feed/category/does-not-exist/")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import pytest
|
|||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category, HomePage, TagMetadata
|
||||||
from apps.blog.tests.factories import AuthorFactory
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
|
|
||||||
|
|
||||||
@@ -40,3 +40,29 @@ def test_tag_metadata_css_and_uniqueness():
|
|||||||
assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
|
assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
TagMetadata.objects.create(tag=tag, colour="pink")
|
TagMetadata.objects.create(tag=tag, colour="pink")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_default_category_is_assigned(home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="Categorised",
|
||||||
|
slug="categorised",
|
||||||
|
author=author,
|
||||||
|
summary="s",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save()
|
||||||
|
assert article.category.slug == "general"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_category_ordering():
|
||||||
|
Category.objects.get_or_create(name="General", slug="general")
|
||||||
|
Category.objects.create(name="Z", slug="z", sort_order=2)
|
||||||
|
Category.objects.create(name="A", slug="a", sort_order=1)
|
||||||
|
names = list(Category.objects.values_list("name", flat=True))
|
||||||
|
assert names == ["General", "A", "Z"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from apps.blog.models import ArticleIndexPage, ArticlePage
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||||
from apps.blog.tests.factories import AuthorFactory
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
from apps.comments.models import Comment
|
from apps.comments.models import Comment
|
||||||
|
|
||||||
@@ -161,3 +161,86 @@ def test_article_index_renders_tag_filter_controls(client, home_page):
|
|||||||
html = resp.content.decode()
|
html = resp.content.decode()
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "/articles/?tag=tag-one" in html
|
assert "/articles/?tag=tag-one" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_category_route_filters_articles(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
reviews = Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
tutorials = Category.objects.create(name="Tutorials", slug="tutorials")
|
||||||
|
review_article = ArticlePage(
|
||||||
|
title="Review A",
|
||||||
|
slug="review-a",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=reviews,
|
||||||
|
)
|
||||||
|
tutorial_article = ArticlePage(
|
||||||
|
title="Tutorial A",
|
||||||
|
slug="tutorial-a",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=tutorials,
|
||||||
|
)
|
||||||
|
index.add_child(instance=review_article)
|
||||||
|
review_article.save_revision().publish()
|
||||||
|
index.add_child(instance=tutorial_article)
|
||||||
|
tutorial_article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/category/reviews/")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Review A" in html
|
||||||
|
assert "Tutorial A" not in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_category_route_supports_tag_filter(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
author = AuthorFactory()
|
||||||
|
reviews = Category.objects.create(name="Reviews", slug="reviews")
|
||||||
|
keep = ArticlePage(
|
||||||
|
title="Keep Me",
|
||||||
|
slug="keep-me",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=reviews,
|
||||||
|
)
|
||||||
|
drop = ArticlePage(
|
||||||
|
title="Drop Me",
|
||||||
|
slug="drop-me",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=reviews,
|
||||||
|
)
|
||||||
|
index.add_child(instance=keep)
|
||||||
|
keep.save_revision().publish()
|
||||||
|
index.add_child(instance=drop)
|
||||||
|
drop.save_revision().publish()
|
||||||
|
target_tag = Tag.objects.create(name="Python", slug="python")
|
||||||
|
keep.tags.add(target_tag)
|
||||||
|
keep.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/articles/category/reviews/?tag=python")
|
||||||
|
html = resp.content.decode()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Keep Me" in html
|
||||||
|
assert "Drop Me" not in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_article_index_category_route_allows_empty_existing_category(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
Category.objects.create(name="Opinion", slug="opinion")
|
||||||
|
|
||||||
|
resp = client.get("/articles/category/opinion/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "No articles found." in resp.content.decode()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from wagtail.snippets.models import register_snippet
|
from wagtail.snippets.models import register_snippet
|
||||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||||
|
|
||||||
from apps.blog.models import TagMetadata
|
from apps.blog.models import Category, TagMetadata
|
||||||
|
|
||||||
|
|
||||||
class TagMetadataViewSet(SnippetViewSet):
|
class TagMetadataViewSet(SnippetViewSet):
|
||||||
@@ -11,3 +11,14 @@ class TagMetadataViewSet(SnippetViewSet):
|
|||||||
|
|
||||||
|
|
||||||
register_snippet(TagMetadataViewSet)
|
register_snippet(TagMetadataViewSet)
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryViewSet(SnippetViewSet):
|
||||||
|
model = Category
|
||||||
|
icon = "folder-open-inverse"
|
||||||
|
list_display = ["name", "slug", "show_in_nav", "sort_order"]
|
||||||
|
list_filter = ["show_in_nav"]
|
||||||
|
ordering = ["sort_order", "name"]
|
||||||
|
|
||||||
|
|
||||||
|
register_snippet(CategoryViewSet)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django import template
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from wagtail.models import Site
|
from wagtail.models import Site
|
||||||
|
|
||||||
from apps.blog.models import TagMetadata
|
from apps.blog.models import ArticleIndexPage, Category, TagMetadata
|
||||||
from apps.core.models import SiteSettings
|
from apps.core.models import SiteSettings
|
||||||
from apps.legal.models import LegalPage
|
from apps.legal.models import LegalPage
|
||||||
|
|
||||||
@@ -46,6 +46,30 @@ def get_social_links(context):
|
|||||||
return list(settings.social_links.all())
|
return list(settings.social_links.all())
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def get_categories_nav(context):
|
||||||
|
request = context.get("request")
|
||||||
|
if not request:
|
||||||
|
return []
|
||||||
|
site = Site.find_for_request(request) if request else None
|
||||||
|
index_qs = ArticleIndexPage.objects.live().public()
|
||||||
|
if site:
|
||||||
|
index_qs = index_qs.in_site(site)
|
||||||
|
index_page = index_qs.first()
|
||||||
|
if not index_page:
|
||||||
|
return []
|
||||||
|
categories = Category.objects.filter(show_in_nav=True).order_by("sort_order", "name")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": category.name,
|
||||||
|
"slug": category.slug,
|
||||||
|
"url": index_page.get_category_url(category),
|
||||||
|
"article_count": index_page.get_articles().filter(category=category).count(),
|
||||||
|
}
|
||||||
|
for category in categories
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_tag_css(tag):
|
def get_tag_css(tag):
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||||
|
from apps.blog.tests.factories import AuthorFactory
|
||||||
from apps.legal.models import LegalIndexPage, LegalPage
|
from apps.legal.models import LegalIndexPage, LegalPage
|
||||||
|
|
||||||
|
|
||||||
@@ -13,3 +15,36 @@ def test_get_legal_pages_tag(client, home_page):
|
|||||||
|
|
||||||
resp = client.get("/")
|
resp = client.get("/")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_categories_nav_tag_renders_category_link(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
category = Category.objects.create(name="Reviews", slug="reviews", show_in_nav=True)
|
||||||
|
author = AuthorFactory()
|
||||||
|
article = ArticlePage(
|
||||||
|
title="R1",
|
||||||
|
slug="r1",
|
||||||
|
author=author,
|
||||||
|
summary="summary",
|
||||||
|
body=[("rich_text", "<p>body</p>")],
|
||||||
|
category=category,
|
||||||
|
)
|
||||||
|
index.add_child(instance=article)
|
||||||
|
article.save_revision().publish()
|
||||||
|
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/articles/category/reviews/" in resp.content.decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_categories_nav_tag_includes_empty_nav_category(client, home_page):
|
||||||
|
index = ArticleIndexPage(title="Articles", slug="articles")
|
||||||
|
home_page.add_child(instance=index)
|
||||||
|
Category.objects.create(name="Benchmarks", slug="benchmarks", show_in_nav=True)
|
||||||
|
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/articles/category/benchmarks/" in resp.content.decode()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.views.generic import RedirectView
|
|||||||
from wagtail import urls as wagtail_urls
|
from wagtail import urls as wagtail_urls
|
||||||
from wagtail.contrib.sitemaps.views import sitemap
|
from wagtail.contrib.sitemaps.views import sitemap
|
||||||
|
|
||||||
from apps.blog.feeds import AllArticlesFeed, TagArticlesFeed
|
from apps.blog.feeds import AllArticlesFeed, CategoryArticlesFeed, TagArticlesFeed
|
||||||
from apps.core.views import consent_view, robots_txt
|
from apps.core.views import consent_view, robots_txt
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path("consent/", consent_view, name="consent"),
|
path("consent/", consent_view, name="consent"),
|
||||||
path("robots.txt", robots_txt, name="robots_txt"),
|
path("robots.txt", robots_txt, name="robots_txt"),
|
||||||
path("feed/", AllArticlesFeed(), name="rss_feed"),
|
path("feed/", AllArticlesFeed(), name="rss_feed"),
|
||||||
|
path("feed/category/<slug:category_slug>/", CategoryArticlesFeed(), name="rss_feed_by_category"),
|
||||||
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
|
path("feed/tag/<slug:tag_slug>/", TagArticlesFeed(), name="rss_feed_by_tag"),
|
||||||
path("sitemap.xml", sitemap),
|
path("sitemap.xml", sitemap),
|
||||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||||
|
|||||||
@@ -13,13 +13,29 @@
|
|||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
||||||
<h1 class="font-display font-black text-4xl md:text-6xl mb-6">{{ page.title }}</h1>
|
{% if active_category %}
|
||||||
|
<nav aria-label="Breadcrumb" class="font-mono text-xs text-zinc-500 mb-4">
|
||||||
|
<a href="/" class="hover:text-brand-cyan">Home</a> / <a href="/articles/" class="hover:text-brand-cyan">Articles</a> / <span>{{ active_category.name }}</span>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
<h1 class="font-display font-black text-4xl md:text-6xl mb-3">{% if active_category %}{{ active_category.name }}{% else %}{{ page.title }}{% endif %}</h1>
|
||||||
|
{% if active_category.description %}
|
||||||
|
<p class="text-zinc-600 dark:text-zinc-400 mb-6">{{ active_category.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Category Filters -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-4">
|
||||||
|
<a href="/articles/{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_category %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_category %}aria-current="page"{% endif %}>Categories</a>
|
||||||
|
{% for category_link in category_links %}
|
||||||
|
<a href="{{ category_link.url }}{% if active_tag %}?tag={{ active_tag }}{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_category and active_category.slug == category_link.category.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_category and active_category.slug == category_link.category.slug %}aria-current="page"{% endif %}>{{ category_link.category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tag Filters -->
|
<!-- Tag Filters -->
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<a href="/articles/" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_tag %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
<a href="{% if active_category %}{{ active_category_url }}{% else %}/articles/{% endif %}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_tag %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
||||||
{% for tag in available_tags %}
|
{% for tag in available_tags %}
|
||||||
<a href="/articles/?tag={{ tag.slug }}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_tag == tag.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
<a href="{% if active_category %}{{ active_category_url }}{% else %}/articles/{% endif %}?tag={{ tag.slug }}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_tag == tag.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,6 +140,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if available_tags %}
|
{% if available_tags %}
|
||||||
|
{% if available_categories %}
|
||||||
|
<div>
|
||||||
|
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Browse Categories</h4>
|
||||||
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
|
{% for category in available_categories %}
|
||||||
|
<a href="/articles/category/{{ category.slug }}/" class="px-3 py-1.5 border border-zinc-200 dark:border-zinc-800 text-sm font-mono hover:border-brand-cyan hover:text-brand-cyan transition-colors">{{ category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Explore Topics</h4>
|
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Explore Topics</h4>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% load static core_tags %}
|
{% load static core_tags %}
|
||||||
{% get_nav_items "header" as header_items %}
|
{% get_nav_items "header" as header_items %}
|
||||||
|
{% get_categories_nav as category_nav_items %}
|
||||||
<nav class="sticky top-0 z-50 backdrop-blur-md bg-brand-light/80 dark:bg-brand-dark/80 border-b border-zinc-200 dark:border-zinc-800 transition-colors">
|
<nav class="sticky top-0 z-50 backdrop-blur-md bg-brand-light/80 dark:bg-brand-dark/80 border-b border-zinc-200 dark:border-zinc-800 transition-colors">
|
||||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
@@ -15,6 +16,9 @@
|
|||||||
{% for item in header_items %}
|
{% for item in header_items %}
|
||||||
<a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
<a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% for category in category_nav_items %}
|
||||||
|
<a href="{{ category.url }}" class="hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
|
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -37,6 +41,9 @@
|
|||||||
{% for item in header_items %}
|
{% for item in header_items %}
|
||||||
<a href="{{ item.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
<a href="{{ item.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% for category in category_nav_items %}
|
||||||
|
<a href="{{ category.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors {% if category.url in request.path %}text-brand-cyan{% endif %}" {% if category.url in request.path %}aria-current="page"{% endif %}>{{ category.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="source" value="nav-mobile" />
|
<input type="hidden" name="source" value="nav-mobile" />
|
||||||
|
|||||||
Reference in New Issue
Block a user