Add category taxonomy and navigation integration
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 14s
CI / pr-e2e (pull_request) Failing after 1m19s

Implements Issue #35 with category snippets, article category routing, category-aware templates, and category RSS feeds with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mark
2026-03-03 11:20:07 +00:00
parent 49baf6a37d
commit e2f71a801c
13 changed files with 404 additions and 18 deletions

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="articles",
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="articles",
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,13 @@ 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, articles__live=True).distinct().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 +64,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()
all_articles = articles
available_categories = (
Category.objects.filter(articles__in=all_articles).distinct().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 +95,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.objects.filter(articles__in=self.get_articles()).distinct(), 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 +121,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 +183,7 @@ class TagMetadata(models.Model):
class ArticlePage(SeoMixin, Page): class ArticlePage(SeoMixin, Page):
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="articles")
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 +198,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 +212,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,75 @@ 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

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, 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 = index_page.get_listing_context(request, active_category=None)["available_categories"].filter(show_in_nav=True)
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,25 @@ 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()

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 %}>All 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 Tags</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" />