Merge pull request 'Implement category taxonomy and navigation (Issue #35)' (#36) from feature/category-navigation-system into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s

Reviewed-on: #36
Reviewed-by: codex_a <codex_a@linteldigital.com>
This commit was merged in pull request #36.
This commit is contained in:
2026-03-03 13:03:26 +00:00
14 changed files with 447 additions and 22 deletions

View File

@@ -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

View File

@@ -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]

View 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",
),
),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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)),

View File

@@ -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>

View File

@@ -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">

View File

@@ -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" />