Implement category taxonomy and navigation (Issue #35) #36
@@ -3,7 +3,7 @@ from django.contrib.syndication.views import Feed
|
||||
from django.shortcuts import get_object_or_404
|
||||
from taggit.models import Tag
|
||||
|
||||
from apps.blog.models import ArticlePage
|
||||
from apps.blog.models import ArticlePage, Category
|
||||
|
||||
|
||||
class AllArticlesFeed(Feed):
|
||||
@@ -48,3 +48,15 @@ class TagArticlesFeed(AllArticlesFeed):
|
||||
|
||||
def items(self, obj):
|
||||
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="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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -7,10 +7,12 @@ from typing import Any
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.db import models
|
||||
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.fields import ParentalKey
|
||||
from taggit.models import Tag, TaggedItemBase
|
||||
from wagtail.admin.panels import FieldPanel, PageChooserPanel
|
||||
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
|
||||
from wagtail.fields import RichTextField, StreamField
|
||||
from wagtail.models import Page
|
||||
from wagtailseo.models import SeoMixin
|
||||
@@ -34,7 +36,7 @@ class HomePage(Page):
|
||||
articles_qs = (
|
||||
ArticlePage.objects.live()
|
||||
.public()
|
||||
.select_related("author")
|
||||
.select_related("author", "category")
|
||||
.prefetch_related("tags__metadata")
|
||||
.order_by("-first_published_at")
|
||||
)
|
||||
@@ -47,10 +49,13 @@ class HomePage(Page):
|
||||
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
|
||||
).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
|
||||
|
||||
|
||||
class ArticleIndexPage(Page):
|
||||
class ArticleIndexPage(RoutablePageMixin, Page):
|
||||
parent_page_types = ["blog.HomePage"]
|
||||
subpage_types = ["blog.ArticlePage"]
|
||||
ARTICLES_PER_PAGE = 12
|
||||
@@ -59,15 +64,24 @@ class ArticleIndexPage(Page):
|
||||
return (
|
||||
ArticlePage.objects.child_of(self)
|
||||
.live()
|
||||
.select_related("author")
|
||||
.select_related("author", "category")
|
||||
.prefetch_related("tags__metadata")
|
||||
.order_by("-first_published_at")
|
||||
)
|
||||
|
||||
def get_context(self, request, *args, **kwargs):
|
||||
ctx = super().get_context(request, *args, **kwargs)
|
||||
def get_category_url(self, category):
|
||||
return f"{self.url}category/{category.slug}/"
|
||||
|
||||
def get_listing_context(self, request, active_category=None):
|
||||
tag_slug = request.GET.get("tag")
|
||||
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 = (
|
||||
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)
|
||||
except EmptyPage:
|
||||
page_obj = paginator.page(paginator.num_pages)
|
||||
ctx["articles"] = page_obj
|
||||
ctx["paginator"] = paginator
|
||||
ctx["active_tag"] = tag_slug
|
||||
ctx["available_tags"] = available_tags
|
||||
return {
|
||||
"articles": page_obj,
|
||||
"paginator": paginator,
|
||||
"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
|
||||
|
||||
|
||||
@@ -92,6 +121,36 @@ class ArticleTag(TaggedItemBase):
|
||||
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):
|
||||
COLOUR_CHOICES = [("cyan", "Cyan"), ("pink", "Pink"), ("neutral", "Neutral")]
|
||||
|
||||
@@ -124,6 +183,7 @@ class TagMetadata(models.Model):
|
||||
|
||||
|
||||
class ArticlePage(SeoMixin, Page):
|
||||
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="articles")
|
||||
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
|
||||
hero_image = models.ForeignKey(
|
||||
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
|
||||
@@ -138,6 +198,7 @@ class ArticlePage(SeoMixin, Page):
|
||||
subpage_types: list[str] = []
|
||||
|
||||
content_panels = Page.content_panels + [
|
||||
FieldPanel("category"),
|
||||
FieldPanel("author"),
|
||||
FieldPanel("hero_image"),
|
||||
FieldPanel("summary"),
|
||||
@@ -151,6 +212,11 @@ class ArticlePage(SeoMixin, Page):
|
||||
search_fields = Page.search_fields
|
||||
|
||||
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()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
|
||||
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
|
||||
@@ -16,3 +18,32 @@ def test_all_feed_methods(article_page):
|
||||
def test_tag_feed_not_found(client):
|
||||
resp = client.get("/feed/tag/does-not-exist/")
|
||||
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 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
|
||||
|
||||
|
||||
@@ -40,3 +40,29 @@ def test_tag_metadata_css_and_uniqueness():
|
||||
assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
|
||||
with pytest.raises(IntegrityError):
|
||||
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
|
||||
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.comments.models import Comment
|
||||
|
||||
@@ -161,3 +161,75 @@ def test_article_index_renders_tag_filter_controls(client, home_page):
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from wagtail.snippets.models import register_snippet
|
||||
from wagtail.snippets.views.snippets import SnippetViewSet
|
||||
|
||||
from apps.blog.models import TagMetadata
|
||||
from apps.blog.models import Category, TagMetadata
|
||||
|
||||
|
||||
class TagMetadataViewSet(SnippetViewSet):
|
||||
@@ -11,3 +11,14 @@ class TagMetadataViewSet(SnippetViewSet):
|
||||
|
||||
|
||||
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 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.legal.models import LegalPage
|
||||
|
||||
@@ -46,6 +46,30 @@ def get_social_links(context):
|
||||
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.filter
|
||||
def get_tag_css(tag):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
|
||||
from apps.blog.tests.factories import AuthorFactory
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
@@ -13,3 +15,25 @@ def test_get_legal_pages_tag(client, home_page):
|
||||
|
||||
resp = client.get("/")
|
||||
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()
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.views.generic import RedirectView
|
||||
from wagtail import urls as wagtail_urls
|
||||
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
|
||||
|
||||
urlpatterns = [
|
||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
||||
path("consent/", consent_view, name="consent"),
|
||||
path("robots.txt", robots_txt, name="robots_txt"),
|
||||
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("sitemap.xml", sitemap),
|
||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||
|
||||
@@ -13,13 +13,29 @@
|
||||
|
||||
<!-- Page Header -->
|
||||
<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 -->
|
||||
<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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,6 +140,16 @@
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
<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">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load static core_tags %}
|
||||
{% 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">
|
||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
@@ -15,6 +16,9 @@
|
||||
{% 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>
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +41,9 @@
|
||||
{% 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>
|
||||
{% 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">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="source" value="nav-mobile" />
|
||||
|
||||
Reference in New Issue
Block a user