1 Commits

Author SHA1 Message Date
codex_a
71d0e71834 fix: use plain StaticFilesStorage in dev settings
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m23s
CompressedManifestStaticFilesStorage requires collectstatic to generate a
manifest before it can serve anything. Dev containers never run collectstatic
so every static asset 404s. Override to StaticFilesStorage in dev so Django
serves files directly from STATICFILES_DIRS and app static directories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-28 20:44:43 +00:00
91 changed files with 282 additions and 4098 deletions

View File

@@ -2,9 +2,6 @@ name: CI
on:
pull_request:
push:
branches:
- main
schedule:
- cron: "0 2 * * *"
@@ -75,22 +72,12 @@ jobs:
runs-on: ubuntu-latest
env:
CI_IMAGE: nohype-ci-e2e:${{ github.run_id }}
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
steps:
- uses: actions/checkout@v4
- name: Build
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
run: |
docker run -d --name pr-e2e-postgres \
@@ -110,15 +97,14 @@ jobs:
- name: Start app with seeded content
run: |
docker run -d --name pr-e2e-app --network container:pr-e2e-postgres \
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
-e SECRET_KEY=ci-secret-key \
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
-e CONSENT_POLICY_VERSION=1 \
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
-e NEWSLETTER_PROVIDER=buttondown \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
-e E2E_MODE=1 \
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
"$CI_IMAGE" \
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
@@ -150,19 +136,10 @@ jobs:
runs-on: ubuntu-latest
env:
CI_IMAGE: nohype-ci-nightly:${{ github.run_id }}
PLAYWRIGHT_CACHE_VOLUME: nohype-playwright-browsers
steps:
- uses: actions/checkout@v4
- name: Build
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
run: |
docker run -d --name nightly-postgres \
@@ -181,15 +158,14 @@ jobs:
- name: Start dev server with seeded content
run: |
docker run -d --name nightly-e2e --network container:nightly-postgres \
-v "$PLAYWRIGHT_CACHE_VOLUME:/ms-playwright:ro" \
-v /opt/playwright-tools/browsers:/opt/playwright-tools/browsers:ro \
-e SECRET_KEY=ci-secret-key \
-e DATABASE_URL=postgres://nohype:nohype@127.0.0.1:5432/nohype \
-e CONSENT_POLICY_VERSION=1 \
-e EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \
-e DEFAULT_FROM_EMAIL=hello@nohypeai.com \
-e NEWSLETTER_PROVIDER=buttondown \
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
-e E2E_MODE=1 \
-e PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-tools/browsers \
"$CI_IMAGE" \
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
@@ -212,15 +188,3 @@ jobs:
- name: Remove CI image
if: always()
run: docker image rm -f "$CI_IMAGE" || true
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy to lintel-prod-01
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: deploy
key: ${{ secrets.PROD_SSH_KEY }}
script: bash /srv/sum/nohype/app/deploy/deploy.sh

View File

@@ -18,7 +18,6 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
libavif-dev \
curl \
nodejs \
npm \

123
Makefile
View File

@@ -1,123 +0,0 @@
DC = docker compose -f /srv/sum/nohype/docker-compose.prod.yml
WEB = $(DC) exec web
MANAGE = $(WEB) python manage.py
.DEFAULT_GOAL := help
# ── Help ──────────────────────────────────────────────────────────────────────
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' \
| sort
# ── Docker ────────────────────────────────────────────────────────────────────
.PHONY: build
build: ## Build / rebuild images
$(DC) build
.PHONY: up
up: ## Start services (detached)
$(DC) up -d
.PHONY: run
run: ## Start services in foreground (with logs)
$(DC) up
.PHONY: down
down: ## Stop and remove containers
$(DC) down
.PHONY: restart
restart: ## Restart all services
$(DC) restart
.PHONY: logs
logs: ## Tail logs for all services (Ctrl-C to stop)
$(DC) logs -f
.PHONY: logs-web
logs-web: ## Tail web service logs
$(DC) logs -f web
.PHONY: ps
ps: ## Show running containers
$(DC) ps
# ── Django ────────────────────────────────────────────────────────────────────
.PHONY: migrate
migrate: ## Apply database migrations
$(MANAGE) migrate --noinput
.PHONY: makemigrations
makemigrations: ## Create new migrations (pass app= to target an app)
$(MANAGE) makemigrations $(app)
.PHONY: showmigrations
showmigrations: ## List all migrations and their status
$(MANAGE) showmigrations
.PHONY: createsuperuser
createsuperuser: ## Create a Django superuser interactively
$(MANAGE) createsuperuser
.PHONY: collectstatic
collectstatic: ## Collect static files
$(MANAGE) collectstatic --noinput
.PHONY: shell
shell: ## Open a Django shell (inside the web container)
$(MANAGE) shell
.PHONY: dbshell
dbshell: ## Open a Django database shell
$(MANAGE) dbshell
.PHONY: bash
bash: ## Open a bash shell inside the web container
$(WEB) bash
.PHONY: psql
psql: ## Open a psql shell in the db container
$(DC) exec db psql -U nohype -d nohype
# ── Tailwind ──────────────────────────────────────────────────────────────────
.PHONY: tailwind-install
tailwind-install: ## Install Tailwind npm dependencies
$(MANAGE) tailwind install --no-input
.PHONY: tailwind-build
tailwind-build: ## Build Tailwind CSS
$(MANAGE) tailwind build
.PHONY: tailwind-watch
tailwind-watch: ## Watch and rebuild Tailwind CSS on changes
$(MANAGE) tailwind start
# ── Testing ───────────────────────────────────────────────────────────────────
.PHONY: test
test: ## Run unit/integration tests with pytest
$(DC) exec web pytest $(args)
.PHONY: test-e2e
test-e2e: ## Run Playwright E2E tests
$(DC) exec web pytest e2e/ $(args)
# ── Custom management commands ────────────────────────────────────────────────
.PHONY: seed
seed: ## Seed deterministic E2E content
$(MANAGE) seed_e2e_content
.PHONY: check-content
check-content: ## Validate live content integrity
$(MANAGE) check_content_integrity
.PHONY: purge-comments
purge-comments: ## Purge old comment personal data (pass months=N to override default 24)
$(MANAGE) purge_old_comment_data $(if $(months),--months $(months),)

View File

@@ -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, Category
from apps.blog.models import ArticlePage
class AllArticlesFeed(Feed):
@@ -16,7 +16,7 @@ class AllArticlesFeed(Feed):
return None
def items(self):
return ArticlePage.objects.live().order_by("-published_date")[:20]
return ArticlePage.objects.live().order_by("-first_published_at")[:20]
def item_title(self, item: ArticlePage):
return item.title
@@ -25,7 +25,7 @@ class AllArticlesFeed(Feed):
return item.summary
def item_pubdate(self, item: ArticlePage):
return item.published_date or item.first_published_at
return item.first_published_at
def item_author_name(self, item: ArticlePage):
return item.author.name
@@ -47,16 +47,4 @@ class TagArticlesFeed(AllArticlesFeed):
return f"No Hype AI — {obj.name}"
def items(self, obj):
return ArticlePage.objects.live().filter(tags=obj).order_by("-published_date")[: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("-published_date")[:20]
return ArticlePage.objects.live().filter(tags=obj).order_by("-first_published_at")[:20]

View File

@@ -1,86 +0,0 @@
# 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

@@ -1,18 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-03 13:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_category_articlepage_category'),
]
operations = [
migrations.AddField(
model_name='articlepage',
name='published_date',
field=models.DateTimeField(blank=True, help_text='Display date for this article. Auto-set on first publish if left blank.', null=True),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-03 13:59
from django.db import migrations
def backfill_published_date(apps, schema_editor):
schema_editor.execute(
"UPDATE blog_articlepage SET published_date = p.first_published_at "
"FROM wagtailcore_page p "
"WHERE blog_articlepage.page_ptr_id = p.id "
"AND blog_articlepage.published_date IS NULL "
"AND p.first_published_at IS NOT NULL"
)
class Migration(migrations.Migration):
dependencies = [
('blog', '0003_add_published_date'),
]
operations = [
migrations.RunPython(backfill_published_date, migrations.RunPython.noop),
]

View File

@@ -7,15 +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, ObjectList, PageChooserPanel, TabbedInterface
from wagtail.contrib.routable_page.models import RoutablePageMixin, route
from wagtail.admin.panels import FieldPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
from wagtail.search import index
from wagtailseo.models import SeoMixin
from apps.blog.blocks import ARTICLE_BODY_BLOCKS
@@ -37,24 +34,18 @@ class HomePage(Page):
articles_qs = (
ArticlePage.objects.live()
.public()
.select_related("author", "category")
.select_related("author")
.prefetch_related("tags__metadata")
.order_by("-published_date")
.order_by("-first_published_at")
)
articles = list(articles_qs[:5])
ctx["featured_article"] = self.featured_article
ctx["latest_articles"] = articles
ctx["more_articles"] = articles[:3]
ctx["available_tags"] = (
Tag.objects.filter(
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).order_by("sort_order", "name")
return ctx
class ArticleIndexPage(RoutablePageMixin, Page):
class ArticleIndexPage(Page):
parent_page_types = ["blog.HomePage"]
subpage_types = ["blog.ArticlePage"]
ARTICLES_PER_PAGE = 12
@@ -63,24 +54,15 @@ class ArticleIndexPage(RoutablePageMixin, Page):
return (
ArticlePage.objects.child_of(self)
.live()
.select_related("author", "category")
.select_related("author")
.prefetch_related("tags__metadata")
.order_by("-published_date")
.order_by("-first_published_at")
)
def get_category_url(self, category):
return f"{self.url}category/{category.slug}/"
def get_listing_context(self, request, active_category=None):
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
tag_slug = request.GET.get("tag")
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 = (
Tag.objects.filter(id__in=articles.values_list("tags__id", flat=True)).distinct().order_by("name")
)
@@ -94,25 +76,10 @@ class ArticleIndexPage(RoutablePageMixin, Page):
page_obj = paginator.page(1)
except EmptyPage:
page_obj = paginator.page(paginator.num_pages)
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, 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))
ctx["articles"] = page_obj
ctx["paginator"] = paginator
ctx["active_tag"] = tag_slug
ctx["available_tags"] = available_tags
return ctx
@@ -120,36 +87,6 @@ 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")]
@@ -158,31 +95,18 @@ class TagMetadata(models.Model):
@classmethod
def get_fallback_css(cls) -> dict[str, str]:
return {
"bg": "bg-zinc-800 dark:bg-zinc-100",
"text": "text-white dark:text-black",
"border": "border-zinc-600/20 dark:border-zinc-400/20",
}
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
def get_css_classes(self) -> dict[str, str]:
mapping = {
"cyan": {
"bg": "bg-brand-cyan/10",
"text": "text-brand-cyan",
"border": "border-brand-cyan/20",
},
"pink": {
"bg": "bg-brand-pink/10",
"text": "text-brand-pink",
"border": "border-brand-pink/20",
},
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
"neutral": self.get_fallback_css(),
}
return mapping.get(self.colour, self.get_fallback_css())
class ArticlePage(SeoMixin, Page):
category = models.ForeignKey("blog.Category", on_delete=PROTECT, related_name="+")
author = models.ForeignKey("authors.Author", on_delete=PROTECT)
hero_image = models.ForeignKey(
"wagtailimages.Image", null=True, blank=True, on_delete=SET_NULL, related_name="+"
@@ -192,78 +116,24 @@ class ArticlePage(SeoMixin, Page):
tags = ClusterTaggableManager(through="blog.ArticleTag", blank=True)
read_time_mins = models.PositiveIntegerField(editable=False, default=1)
comments_enabled = models.BooleanField(default=True)
published_date = models.DateTimeField(
null=True,
blank=True,
help_text="Display date for this article. Auto-set on first publish if left blank.",
)
parent_page_types = ["blog.ArticleIndexPage"]
subpage_types: list[str] = []
content_panels = [
FieldPanel("title"),
content_panels = Page.content_panels + [
FieldPanel("author"),
FieldPanel("hero_image"),
FieldPanel("summary"),
FieldPanel("body"),
]
metadata_panels = [
FieldPanel("category"),
FieldPanel("author"),
FieldPanel("tags"),
FieldPanel("hero_image"),
FieldPanel("comments_enabled"),
]
publishing_panels = [
FieldPanel("published_date"),
FieldPanel("go_live_at"),
FieldPanel("expire_at"),
]
promote_panels = Page.promote_panels + SeoMixin.seo_panels
edit_handler = TabbedInterface(
[
ObjectList(content_panels, heading="Content"),
ObjectList(metadata_panels, heading="Metadata"),
ObjectList(publishing_panels, heading="Publishing"),
ObjectList(
Page.promote_panels + SeoMixin.seo_panels,
heading="SEO",
),
]
)
search_fields = Page.search_fields + [
index.SearchField("summary"),
index.SearchField("body_text", es_extra={"analyzer": "english"}),
index.AutocompleteField("title"),
index.RelatedFields("tags", [
index.SearchField("name"),
]),
index.FilterField("category"),
index.FilterField("published_date"),
]
@property
def body_text(self) -> str:
"""Extract prose text from body StreamField, excluding code blocks."""
parts: list[str] = []
for block in self.body:
if block.block_type == "code":
continue
value = block.value
text = value.source if hasattr(value, "source") else str(value)
parts.append(text)
return " ".join(parts)
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"},
)
if not self.published_date and self.first_published_at:
self.published_date = self.first_published_at
self.read_time_mins = self._compute_read_time()
return super().save(*args, **kwargs)
@@ -288,14 +158,14 @@ class ArticlePage(SeoMixin, Page):
.filter(tags__in=tag_ids)
.exclude(pk=self.pk)
.distinct()
.order_by("-published_date")[:count]
.order_by("-first_published_at")[:count]
)
if len(related) < count:
exclude_ids = [a.pk for a in related] + [self.pk]
fallback = list(
ArticlePage.objects.live()
.exclude(pk__in=exclude_ids)
.order_by("-published_date")[: count - len(related)]
.order_by("-first_published_at")[: count - len(related)]
)
return related + fallback
return related
@@ -303,20 +173,12 @@ class ArticlePage(SeoMixin, Page):
def get_context(self, request, *args, **kwargs):
ctx = super().get_context(request, *args, **kwargs)
ctx["related_articles"] = self.get_related_articles()
from django.conf import settings
from apps.comments.models import Comment
from apps.comments.views import _annotate_reaction_counts, _get_session_key
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
comments = list(
self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
ctx["approved_comments"] = self.comments.filter(is_approved=True, parent__isnull=True).prefetch_related(
Prefetch("replies", queryset=approved_replies)
)
)
_annotate_reaction_counts(comments, _get_session_key(request))
ctx["approved_comments"] = comments
ctx["turnstile_site_key"] = getattr(settings, "TURNSTILE_SITE_KEY", "")
return ctx

View File

@@ -37,7 +37,6 @@ class ArticlePageFactory(wagtail_factories.PageFactory):
summary = "Summary"
body = [("rich_text", "<p>Hello world</p>")]
first_published_at = factory.LazyFunction(timezone.now)
published_date = factory.LazyFunction(timezone.now)
class LegalIndexPageFactory(wagtail_factories.PageFactory):

View File

@@ -1,275 +0,0 @@
from datetime import timedelta
import pytest
from django.test import override_settings
from django.utils import timezone
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@pytest.mark.django_db
def test_published_date_auto_set_on_first_publish(home_page):
"""published_date should be auto-populated from first_published_at on first publish."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Auto Date",
slug="auto-date",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
article.refresh_from_db()
assert article.published_date is not None
assert article.published_date == article.first_published_at
@pytest.mark.django_db
def test_published_date_preserved_when_explicitly_set(home_page):
"""An explicitly set published_date should not be overwritten on save."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
custom_date = timezone.now() - timedelta(days=30)
article = ArticlePage(
title="Custom Date",
slug="custom-date",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
published_date=custom_date,
)
index.add_child(instance=article)
article.save_revision().publish()
article.refresh_from_db()
assert article.published_date == custom_date
@pytest.mark.django_db
def test_homepage_orders_articles_by_published_date(home_page):
"""HomePage context should list articles ordered by -published_date."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
older = ArticlePage(
title="Older",
slug="older",
author=author,
summary="s",
body=[("rich_text", "<p>body</p>")],
published_date=timezone.now() - timedelta(days=10),
)
index.add_child(instance=older)
older.save_revision().publish()
newer = ArticlePage(
title="Newer",
slug="newer",
author=author,
summary="s",
body=[("rich_text", "<p>body</p>")],
published_date=timezone.now(),
)
index.add_child(instance=newer)
newer.save_revision().publish()
ctx = home_page.get_context(type("Req", (), {"GET": {}})())
titles = [a.title for a in ctx["latest_articles"]]
assert titles.index("Newer") < titles.index("Older")
@pytest.mark.django_db
def test_article_index_orders_by_published_date(home_page, rf):
"""ArticleIndexPage.get_articles should order by -published_date."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
old = ArticlePage(
title="Old",
slug="old",
author=author,
summary="s",
body=[("rich_text", "<p>b</p>")],
published_date=timezone.now() - timedelta(days=5),
)
index.add_child(instance=old)
old.save_revision().publish()
new = ArticlePage(
title="New",
slug="new",
author=author,
summary="s",
body=[("rich_text", "<p>b</p>")],
published_date=timezone.now(),
)
index.add_child(instance=new)
new.save_revision().publish()
articles = list(index.get_articles())
assert articles[0].title == "New"
assert articles[1].title == "Old"
@pytest.mark.django_db
def test_feed_uses_published_date(article_page):
"""RSS feed item_pubdate should use published_date."""
from apps.blog.feeds import AllArticlesFeed
feed = AllArticlesFeed()
assert feed.item_pubdate(article_page) == article_page.published_date
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_viewset_loads(client, django_user_model, home_page):
"""The Articles PageListingViewSet index page should load."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/")
assert response.status_code == 200
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_shows_articles(client, django_user_model, home_page):
"""The Articles listing should show existing articles."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Listed Article",
slug="listed-article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/")
assert response.status_code == 200
assert "Listed Article" in response.content.decode()
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_dashboard_panel_renders(client, django_user_model, home_page):
"""The Wagtail admin dashboard should include the articles summary panel."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/")
assert response.status_code == 200
content = response.content.decode()
assert "Articles overview" in content
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_dashboard_panel_shows_drafts(client, django_user_model, home_page):
"""Dashboard panel should list draft articles."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
draft = ArticlePage(
title="My Draft Post",
slug="draft-post",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=draft)
draft.save_revision() # save revision but don't publish
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/")
content = response.content.decode()
assert "My Draft Post" in content
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_article_edit_page_has_tabbed_interface(client, django_user_model, home_page):
"""ArticlePage editor should have tabbed panels (Content, Metadata, Publishing, SEO)."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Tabbed",
slug="tabbed",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get(f"/cms/pages/{article.pk}/edit/")
content = response.content.decode()
assert response.status_code == 200
assert "Content" in content
assert "Metadata" in content
assert "Publishing" in content
assert "SEO" in content
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_has_status_filter(client, django_user_model, home_page):
"""The Articles listing should accept status filter parameter."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/?status=live")
assert response.status_code == 200
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_articles_listing_has_tag_filter(client, django_user_model, home_page):
"""The Articles listing should accept tag filter parameter."""
admin = django_user_model.objects.create_superuser(
username="admin", email="admin@example.com", password="admin-pass"
)
client.force_login(admin)
response = client.get("/cms/articles/?tag=1")
assert response.status_code == 200
@pytest.mark.django_db
def test_article_listing_default_ordering():
"""ArticlePageListingViewSet should default to -published_date ordering."""
from apps.blog.wagtail_hooks import ArticlePageListingViewSet
assert ArticlePageListingViewSet.default_ordering == "-published_date"
@pytest.mark.django_db
def test_article_search_fields_include_summary():
"""ArticlePage.search_fields should index the summary field."""
field_names = [
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
]
assert "summary" in field_names

View File

@@ -1,8 +1,6 @@
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
@@ -18,32 +16,3 @@ 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

View File

@@ -2,7 +2,7 @@ import pytest
from django.db import IntegrityError
from taggit.models import Tag
from apps.blog.models import ArticleIndexPage, ArticlePage, Category, HomePage, TagMetadata
from apps.blog.models import ArticleIndexPage, ArticlePage, HomePage, TagMetadata
from apps.blog.tests.factories import AuthorFactory
@@ -37,32 +37,6 @@ def test_article_compute_read_time_excludes_code(home_page):
def test_tag_metadata_css_and_uniqueness():
tag = Tag.objects.create(name="llms", slug="llms")
meta = TagMetadata.objects.create(tag=tag, colour="cyan")
assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
assert meta.get_css_classes()["bg"].startswith("bg-cyan")
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"]

View File

@@ -1,140 +0,0 @@
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.blog.views import MAX_QUERY_LENGTH
@pytest.fixture
def search_articles(home_page):
"""Create an article index with searchable articles."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
articles = []
for title, summary in [
("Understanding LLM Benchmarks", "A deep dive into how language models are evaluated"),
("Local Models on Apple Silicon", "Running open-source models on your MacBook"),
("Agent Frameworks Compared", "Comparing LangChain, CrewAI, and AutoGen"),
]:
a = ArticlePage(
title=title,
slug=title.lower().replace(" ", "-"),
author=author,
summary=summary,
body=[("rich_text", f"<p>{summary} in detail.</p>")],
)
index.add_child(instance=a)
a.save_revision().publish()
articles.append(a)
return articles
@pytest.mark.django_db
class TestSearchView:
def test_empty_query_returns_no_results(self, client, home_page):
resp = client.get("/search/")
assert resp.status_code == 200
assert resp.context["query"] == ""
assert resp.context["results"] is None
def test_whitespace_query_returns_no_results(self, client, home_page):
resp = client.get("/search/?q= ")
assert resp.status_code == 200
assert resp.context["query"] == ""
assert resp.context["results"] is None
def test_search_returns_matching_articles(self, client, search_articles):
resp = client.get("/search/?q=benchmarks")
assert resp.status_code == 200
assert resp.context["query"] == "benchmarks"
assert resp.context["results"] is not None
def test_search_no_match_returns_empty_page(self, client, search_articles):
resp = client.get("/search/?q=zzzznonexistent")
assert resp.status_code == 200
assert resp.context["query"] == "zzzznonexistent"
# Either None or empty page object
results = resp.context["results"]
if results is not None:
assert len(list(results)) == 0
def test_query_is_truncated_to_max_length(self, client, home_page):
long_query = "a" * 500
resp = client.get(f"/search/?q={long_query}")
assert resp.status_code == 200
assert len(resp.context["query"]) <= MAX_QUERY_LENGTH
def test_query_preserved_in_template(self, client, search_articles):
resp = client.get("/search/?q=LLM")
html = resp.content.decode()
assert 'value="LLM"' in html
def test_search_results_page_renders(self, client, search_articles):
resp = client.get("/search/?q=models")
assert resp.status_code == 200
html = resp.content.decode()
assert "Search" in html
def test_search_url_resolves(self, client, home_page):
from django.urls import reverse
assert reverse("search") == "/search/"
@pytest.mark.django_db
class TestSearchFields:
def test_search_fields_include_summary(self):
field_names = [
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
]
assert "summary" in field_names
def test_search_fields_include_body_text(self):
field_names = [
f.field_name for f in ArticlePage.search_fields if hasattr(f, "field_name")
]
assert "body_text" in field_names
def test_search_fields_include_autocomplete_title(self):
from wagtail.search.index import AutocompleteField
autocomplete_fields = [
f for f in ArticlePage.search_fields if isinstance(f, AutocompleteField)
]
assert any(f.field_name == "title" for f in autocomplete_fields)
def test_search_fields_include_related_tags(self):
from wagtail.search.index import RelatedFields
related = [f for f in ArticlePage.search_fields if isinstance(f, RelatedFields)]
assert any(f.field_name == "tags" for f in related)
def test_body_text_excludes_code_blocks(self):
author = AuthorFactory()
article = ArticlePage(
title="Test",
slug="test",
author=author,
summary="summary",
body=[
("rich_text", "<p>prose content here</p>"),
("code", {"language": "python", "filename": "", "raw_code": "def secret(): pass"}),
],
)
assert "prose content here" in article.body_text
assert "secret" not in article.body_text
@pytest.mark.django_db
class TestSearchNavIntegration:
def test_nav_contains_search_form(self, client, home_page):
resp = client.get("/")
html = resp.content.decode()
assert 'role="search"' in html
assert 'name="q"' in html
assert 'placeholder="Search articles..."' in html
def test_article_index_contains_search_form(self, client, home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
resp = client.get("/articles/")
html = resp.content.decode()
assert 'name="q"' in html

View File

@@ -1,7 +1,7 @@
import pytest
from taggit.models import Tag
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment
@@ -69,13 +69,8 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page):
resp = client.get("/")
html = resp.content.decode()
assert resp.status_code == 200
# Nav has a search form instead of Subscribe CTA
assert 'role="search"' in html
assert 'name="q"' in html
# Footer has Connect section with social/RSS links (no newsletter form)
assert "Connect" in html
assert 'name="source" value="nav"' not in html
assert 'name="source" value="footer"' not in html
assert 'name="source" value="nav"' in html
assert 'name="source" value="footer"' in html
@pytest.mark.django_db
@@ -162,86 +157,3 @@ 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
@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,43 +0,0 @@
from __future__ import annotations
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse
from apps.blog.models import ArticlePage
RESULTS_PER_PAGE = 12
MAX_QUERY_LENGTH = 200
def search(request: HttpRequest) -> HttpResponse:
query = request.GET.get("q", "").strip()[:MAX_QUERY_LENGTH]
results_page = None
paginator = None
if query:
results = (
ArticlePage.objects.live()
.public()
.select_related("author", "category")
.prefetch_related("tags__metadata")
.search(query)
)
paginator = Paginator(results, RESULTS_PER_PAGE)
page_num = request.GET.get("page")
try:
results_page = paginator.page(page_num)
except PageNotAnInteger:
results_page = paginator.page(1)
except EmptyPage:
results_page = paginator.page(paginator.num_pages)
return TemplateResponse(
request,
"blog/search_results.html",
{
"query": query,
"results": results_page,
"paginator": paginator,
},
)

View File

@@ -1,22 +1,7 @@
import django_filters
from taggit.models import Tag
from wagtail import hooks
from wagtail.admin.filters import WagtailFilterSet
from wagtail.admin.ui.components import Component
from wagtail.admin.ui.tables import Column, DateColumn
from wagtail.admin.ui.tables.pages import BulkActionsColumn, PageStatusColumn, PageTitleColumn
from wagtail.admin.viewsets.pages import PageListingViewSet
from wagtail.snippets.models import register_snippet
from wagtail.snippets.views.snippets import SnippetViewSet
from apps.authors.models import Author
from apps.blog.models import ArticlePage, Category, TagMetadata
STATUS_CHOICES = [
("live", "Published"),
("draft", "Draft"),
("scheduled", "Scheduled"),
]
from apps.blog.models import TagMetadata
class TagMetadataViewSet(SnippetViewSet):
@@ -26,106 +11,3 @@ 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)
# ── Articles page listing ────────────────────────────────────────────────────
class StatusFilter(django_filters.ChoiceFilter):
def filter(self, qs, value): # noqa: A003
if value == "live":
return qs.filter(live=True)
if value == "draft":
return qs.filter(live=False, go_live_at__isnull=True)
if value == "scheduled":
return qs.filter(live=False, go_live_at__isnull=False)
return qs
class ArticleFilterSet(WagtailFilterSet):
category = django_filters.ModelChoiceFilter(
queryset=Category.objects.all(),
empty_label="All categories",
)
author = django_filters.ModelChoiceFilter(
queryset=Author.objects.all(),
empty_label="All authors",
)
status = StatusFilter(
choices=STATUS_CHOICES,
empty_label="All statuses",
)
tag = django_filters.ModelChoiceFilter(
field_name="tags",
queryset=Tag.objects.all(),
empty_label="All tags",
)
class Meta:
model = ArticlePage
fields = []
class ArticlePageListingViewSet(PageListingViewSet):
model = ArticlePage
icon = "doc-full"
menu_label = "Articles"
menu_order = 200
add_to_admin_menu = True
name = "articles"
columns = [
BulkActionsColumn("bulk_actions"),
PageTitleColumn("title", classname="title"),
Column("author", label="Author", sort_key="author__name"),
Column("category", label="Category"),
DateColumn("published_date", label="Published", sort_key="published_date"),
PageStatusColumn("status", sort_key="live"),
]
filterset_class = ArticleFilterSet
default_ordering = "-published_date"
@hooks.register("register_admin_viewset")
def register_article_listing():
return ArticlePageListingViewSet("articles")
# ── Dashboard panel ──────────────────────────────────────────────────────────
class ArticlesSummaryPanel(Component):
name = "articles_summary"
template_name = "blog/panels/articles_summary.html"
order = 110
def get_context_data(self, parent_context):
context = super().get_context_data(parent_context)
context["drafts"] = (
ArticlePage.objects.not_live()
.order_by("-latest_revision_created_at")[:5]
)
context["scheduled"] = (
ArticlePage.objects.filter(go_live_at__isnull=False, live=False)
.order_by("go_live_at")[:5]
)
context["recent"] = (
ArticlePage.objects.live()
.order_by("-published_date")[:5]
)
return context
@hooks.register("construct_homepage_panels")
def add_articles_summary_panel(request, panels):
panels.append(ArticlesSummaryPanel())

View File

@@ -5,7 +5,7 @@ from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.comments.models import Comment, CommentReaction
from apps.comments.models import Comment
class Command(BaseCommand):
@@ -29,10 +29,3 @@ class Command(BaseCommand):
.update(author_email="", ip_address=None)
)
self.stdout.write(self.style.SUCCESS(f"Purged personal data for {purged} comment(s)."))
reactions_purged = (
CommentReaction.objects.filter(created_at__lt=cutoff)
.exclude(session_key="")
.update(session_key="")
)
self.stdout.write(self.style.SUCCESS(f"Purged session keys for {reactions_purged} reaction(s)."))

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-03 22:49
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CommentReaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reaction_type', models.CharField(choices=[('heart', '❤️'), ('plus_one', '👍')], max_length=20)),
('session_key', models.CharField(max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('comment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='comments.comment')),
],
options={
'constraints': [models.UniqueConstraint(fields=('comment', 'reaction_type', 'session_key'), name='unique_comment_reaction_per_session')],
},
),
]

View File

@@ -23,21 +23,3 @@ class Comment(models.Model):
def __str__(self) -> str:
return f"Comment by {self.author_name}"
class CommentReaction(models.Model):
comment = models.ForeignKey(Comment, on_delete=models.CASCADE, related_name="reactions")
reaction_type = models.CharField(max_length=20, choices=[("heart", "❤️"), ("plus_one", "👍")])
session_key = models.CharField(max_length=64)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["comment", "reaction_type", "session_key"],
name="unique_comment_reaction_per_session",
)
]
def __str__(self) -> str:
return f"{self.reaction_type} on comment {self.comment_id}"

View File

@@ -1,5 +1,4 @@
import pytest
from django.test import override_settings
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
@@ -80,18 +79,3 @@ def test_bulk_approve_action_marks_selected_pending_comments_as_approved(home_pa
assert child_updates == 0
assert pending.is_approved is True
assert approved.is_approved is True
@pytest.mark.django_db
@override_settings(ALLOWED_HOSTS=["testserver", "localhost", "127.0.0.1"])
def test_comments_snippet_index_page_loads(client, django_user_model, home_page):
admin = django_user_model.objects.create_superuser(
username="admin",
email="admin@example.com",
password="admin-pass",
)
client.force_login(admin)
response = client.get("/cms/snippets/comments/comment/")
assert response.status_code == 200

View File

@@ -1,6 +1,5 @@
import pytest
from django.core.cache import cache
from django.test import override_settings
from apps.comments.forms import CommentForm
@@ -12,7 +11,6 @@ def test_comment_form_rejects_blank_body():
@pytest.mark.django_db
@override_settings(COMMENT_RATE_LIMIT_PER_MINUTE=3)
def test_comment_rate_limit(client, article_page):
cache.clear()
payload = {

View File

@@ -1,325 +0,0 @@
"""Tests for Comments v2: HTMX, Turnstile, reactions, polling, CSP."""
from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch
import pytest
from django.core.cache import cache
from django.core.management import call_command
from django.test import override_settings
from django.utils import timezone
from apps.blog.models import ArticleIndexPage, ArticlePage
from apps.blog.tests.factories import AuthorFactory
from apps.comments.models import Comment, CommentReaction
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def _article(home_page):
"""Create a published article with comments enabled."""
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(
title="Test Article",
slug="test-article",
author=author,
summary="summary",
body=[("rich_text", "<p>body</p>")],
)
index.add_child(instance=article)
article.save_revision().publish()
return article
@pytest.fixture
def approved_comment(_article):
return Comment.objects.create(
article=_article,
author_name="Alice",
author_email="alice@example.com",
body="Great article!",
is_approved=True,
)
def _post_comment(client, article, extra=None, htmx=False):
cache.clear()
payload = {
"article_id": article.id,
"author_name": "Test",
"author_email": "test@example.com",
"body": "Hello world",
"honeypot": "",
}
if extra:
payload.update(extra)
headers = {}
if htmx:
headers["HTTP_HX_REQUEST"] = "true"
return client.post("/comments/post/", payload, **headers)
# ── HTMX Response Contracts ──────────────────────────────────────────────────
@pytest.mark.django_db
def test_htmx_post_returns_form_with_moderation_on_success(client, _article):
"""HTMX POST with Turnstile disabled returns fresh form + moderation message."""
resp = _post_comment(client, _article, htmx=True)
assert resp.status_code == 200
assert b"awaiting moderation" in resp.content
# Response swaps the form container (contains form + success message)
assert b"comment-form-container" in resp.content
assert "HX-Request" in resp["Vary"]
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_htmx_post_returns_form_plus_oob_comment_when_approved(client, _article):
"""HTMX POST with successful Turnstile returns fresh form + OOB comment."""
with patch("apps.comments.views._verify_turnstile", return_value=True):
resp = _post_comment(client, _article, extra={"cf-turnstile-response": "tok"}, htmx=True)
assert resp.status_code == 200
content = resp.content.decode()
# Fresh form container is the primary response
assert "comment-form-container" in content
assert "Comment posted!" in content
# OOB swap appends the comment to #comments-list
assert "hx-swap-oob" in content
assert "Hello world" in content
comment = Comment.objects.get()
assert comment.is_approved is True
@pytest.mark.django_db
def test_htmx_post_returns_form_with_errors_on_invalid(client, _article):
"""HTMX POST with invalid data returns form with errors (HTTP 200)."""
cache.clear()
resp = client.post(
"/comments/post/",
{"article_id": _article.id, "author_name": "T", "author_email": "t@t.com", "body": " ", "honeypot": ""},
HTTP_HX_REQUEST="true",
)
assert resp.status_code == 200
assert b"comment-form-container" in resp.content
assert b"Comment form errors" in resp.content
assert "HX-Request" in resp["Vary"]
assert Comment.objects.count() == 0
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_htmx_reply_returns_oob_reply_when_approved(client, _article, approved_comment):
"""Approved reply via HTMX returns compact reply partial via OOB swap."""
cache.clear()
with patch("apps.comments.views._verify_turnstile", return_value=True):
resp = client.post(
"/comments/post/",
{
"article_id": _article.id,
"parent_id": approved_comment.id,
"author_name": "Replier",
"author_email": "r@r.com",
"body": "Nice reply",
"honeypot": "",
"cf-turnstile-response": "tok",
},
HTTP_HX_REQUEST="true",
)
content = resp.content.decode()
assert resp.status_code == 200
# OOB targets the sibling .replies-container of the parent comment article
assert f'hx-swap-oob="beforeend:#comment-{approved_comment.id} ~ .replies-container"' in content
# Verify content is rendered (not empty due to context mismatch)
assert "Replier" in content
assert "Nice reply" in content
reply = Comment.objects.exclude(pk=approved_comment.pk).get()
assert f"comment-{reply.id}" in content
assert reply.parent_id == approved_comment.id
assert reply.is_approved is True
@pytest.mark.django_db
def test_non_htmx_post_still_redirects(client, _article):
"""Non-HTMX POST continues to redirect (progressive enhancement)."""
resp = _post_comment(client, _article)
assert resp.status_code == 302
assert resp["Location"].endswith("?commented=1")
@pytest.mark.django_db
def test_htmx_error_with_tampered_parent_id_falls_back_to_main_form(client, _article):
"""Tampered/non-numeric parent_id falls back to main form error response."""
cache.clear()
resp = client.post(
"/comments/post/",
{"article_id": _article.id, "parent_id": "not-a-number", "author_name": "T",
"author_email": "t@t.com", "body": " ", "honeypot": ""},
HTTP_HX_REQUEST="true",
)
assert resp.status_code == 200
assert b"comment-form-container" in resp.content
# ── Turnstile Integration ────────────────────────────────────────────────────
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_turnstile_failure_keeps_comment_unapproved(client, _article):
"""When Turnstile verification fails, comment stays unapproved."""
with patch("apps.comments.views._verify_turnstile", return_value=False):
_post_comment(client, _article, extra={"cf-turnstile-response": "bad-tok"})
comment = Comment.objects.get()
assert comment.is_approved is False
@pytest.mark.django_db
def test_turnstile_disabled_keeps_comment_unapproved(client, _article):
"""When TURNSTILE_SECRET_KEY is empty, comment stays unapproved."""
_post_comment(client, _article)
comment = Comment.objects.get()
assert comment.is_approved is False
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret", TURNSTILE_EXPECTED_HOSTNAME="nohypeai.com")
def test_turnstile_hostname_mismatch_rejects(client, _article):
"""Turnstile hostname mismatch keeps comment unapproved."""
mock_resp = type("R", (), {"json": lambda self: {"success": True, "hostname": "evil.com"}})()
with patch("apps.comments.views.http_requests.post", return_value=mock_resp):
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
comment = Comment.objects.get()
assert comment.is_approved is False
@pytest.mark.django_db
@override_settings(TURNSTILE_SECRET_KEY="test-secret")
def test_turnstile_timeout_fails_closed(client, _article):
"""Network error during Turnstile verification fails closed."""
with patch("apps.comments.views.http_requests.post", side_effect=Exception("timeout")):
_post_comment(client, _article, extra={"cf-turnstile-response": "tok"})
comment = Comment.objects.get()
assert comment.is_approved is False
# ── Polling ───────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_comment_poll_returns_new_comments(_article, client, approved_comment):
"""Poll endpoint returns only comments after the given ID."""
resp = client.get(f"/comments/poll/{_article.id}/?after_id=0")
assert resp.status_code == 200
assert b"Alice" in resp.content
resp2 = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
assert resp2.status_code == 200
assert b"Alice" not in resp2.content
@pytest.mark.django_db
def test_comment_poll_no_duplicates(_article, client, approved_comment):
"""Polling with current latest ID returns empty."""
resp = client.get(f"/comments/poll/{_article.id}/?after_id={approved_comment.id}")
assert b"comment-" not in resp.content
# ── Reactions ─────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_react_creates_reaction(client, approved_comment):
cache.clear()
resp = client.post(
f"/comments/{approved_comment.id}/react/",
{"reaction_type": "heart"},
HTTP_HX_REQUEST="true",
)
assert resp.status_code == 200
assert CommentReaction.objects.count() == 1
@pytest.mark.django_db
def test_react_toggle_removes_reaction(client, approved_comment):
"""Second reaction of same type removes it (toggle)."""
cache.clear()
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
assert CommentReaction.objects.count() == 0
@pytest.mark.django_db
def test_react_different_types_coexist(client, approved_comment):
cache.clear()
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
assert CommentReaction.objects.count() == 2
@pytest.mark.django_db
def test_react_invalid_type_returns_400(client, approved_comment):
cache.clear()
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "invalid"})
assert resp.status_code == 400
@pytest.mark.django_db
def test_react_on_unapproved_comment_returns_404(client, _article):
cache.clear()
comment = Comment.objects.create(
article=_article, author_name="B", author_email="b@b.com", body="x", is_approved=False,
)
resp = client.post(f"/comments/{comment.id}/react/", {"reaction_type": "heart"})
assert resp.status_code == 404
@pytest.mark.django_db
@override_settings(REACTION_RATE_LIMIT_PER_MINUTE=2)
def test_react_rate_limit(client, approved_comment):
cache.clear()
for _ in range(2):
client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "heart"})
resp = client.post(f"/comments/{approved_comment.id}/react/", {"reaction_type": "plus_one"})
assert resp.status_code == 429
# ── CSP ───────────────────────────────────────────────────────────────────────
@pytest.mark.django_db
def test_csp_allows_turnstile(client, _article):
"""CSP header includes Cloudflare Turnstile domains."""
resp = client.get(_article.url)
csp = resp.get("Content-Security-Policy", "")
assert "challenges.cloudflare.com" in csp
assert "frame-src" in csp
# ── Purge Command Extension ──────────────────────────────────────────────────
@pytest.mark.django_db
def test_purge_clears_reaction_session_keys(home_page):
index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=index)
author = AuthorFactory()
article = ArticlePage(title="A", slug="a", author=author, summary="s", body=[("rich_text", "<p>b</p>")])
index.add_child(instance=article)
article.save_revision().publish()
comment = Comment.objects.create(
article=article, author_name="X", author_email="x@x.com", body="y", is_approved=True,
)
reaction = CommentReaction.objects.create(
comment=comment, reaction_type="heart", session_key="abc123",
)
CommentReaction.objects.filter(pk=reaction.pk).update(created_at=timezone.now() - timedelta(days=800))
call_command("purge_old_comment_data")
reaction.refresh_from_db()
assert reaction.session_key == ""

View File

@@ -1,9 +1,7 @@
from django.urls import path
from apps.comments.views import CommentCreateView, comment_poll, comment_react
from apps.comments.views import CommentCreateView
urlpatterns = [
path("post/", CommentCreateView.as_view(), name="comment_post"),
path("poll/<int:article_id>/", comment_poll, name="comment_poll"),
path("<int:comment_id>/react/", comment_react, name="comment_react"),
]

View File

@@ -1,26 +1,16 @@
from __future__ import annotations
import logging
import requests as http_requests
from django.conf import settings
from django.contrib import messages
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import Count, Prefetch
from django.http import HttpResponse, JsonResponse
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.utils.cache import patch_vary_headers
from django.views import View
from django.views.decorators.http import require_GET, require_POST
from apps.blog.models import ArticlePage
from apps.comments.forms import CommentForm
from apps.comments.models import Comment, CommentReaction
logger = logging.getLogger(__name__)
from apps.comments.models import Comment
def client_ip_from_request(request) -> str:
@@ -32,163 +22,18 @@ def client_ip_from_request(request) -> str:
return remote_addr
def _is_htmx(request) -> bool:
return request.headers.get("HX-Request") == "true"
def _add_vary_header(response):
patch_vary_headers(response, ["HX-Request"])
return response
def _verify_turnstile(token: str, ip: str) -> bool:
secret = getattr(settings, "TURNSTILE_SECRET_KEY", "")
if not secret:
return False
try:
resp = http_requests.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data={"secret": secret, "response": token, "remoteip": ip},
timeout=5,
)
result = resp.json()
if not result.get("success"):
return False
expected_hostname = getattr(settings, "TURNSTILE_EXPECTED_HOSTNAME", "")
if expected_hostname and result.get("hostname") != expected_hostname:
logger.warning("Turnstile hostname mismatch: %s", result.get("hostname"))
return False
return True
except Exception:
logger.exception("Turnstile verification failed")
return False
def _turnstile_enabled() -> bool:
return bool(getattr(settings, "TURNSTILE_SECRET_KEY", ""))
def _get_session_key(request) -> str:
session = getattr(request, "session", None)
return (session.session_key or "") if session else ""
def _turnstile_site_key():
return getattr(settings, "TURNSTILE_SITE_KEY", "")
def _annotate_reaction_counts(comments, session_key=""):
"""Hydrate each comment with reaction_counts dict and user_reacted set."""
comment_ids = [c.id for c in comments]
if not comment_ids:
return comments
counts_qs = (
CommentReaction.objects.filter(comment_id__in=comment_ids)
.values("comment_id", "reaction_type")
.annotate(count=Count("id"))
)
counts_map = {}
for row in counts_qs:
counts_map.setdefault(row["comment_id"], {"heart": 0, "plus_one": 0})
counts_map[row["comment_id"]][row["reaction_type"]] = row["count"]
user_map = {}
if session_key:
user_qs = CommentReaction.objects.filter(
comment_id__in=comment_ids, session_key=session_key
).values_list("comment_id", "reaction_type")
for cid, rtype in user_qs:
user_map.setdefault(cid, set()).add(rtype)
for comment in comments:
comment.reaction_counts = counts_map.get(comment.id, {"heart": 0, "plus_one": 0})
comment.user_reacted = user_map.get(comment.id, set())
return comments
def _comment_template_context(comment, article, request):
"""Build template context for a single comment partial."""
_annotate_reaction_counts([comment], _get_session_key(request))
return {
"comment": comment,
"page": article,
"turnstile_site_key": _turnstile_site_key(),
}
class CommentCreateView(View):
def _render_htmx_error(self, request, article, form):
"""Return error form partial for HTMX — swaps the form container itself."""
raw_parent_id = request.POST.get("parent_id")
if raw_parent_id:
try:
parent_id = int(raw_parent_id)
except (ValueError, TypeError):
parent_id = None
parent = Comment.objects.filter(pk=parent_id, article=article).first() if parent_id else None
if parent:
ctx = {
"comment": parent, "page": article,
"turnstile_site_key": _turnstile_site_key(),
"reply_form_errors": form.errors,
}
return _add_vary_header(render(request, "comments/_reply_form.html", ctx))
ctx = {
"comment_form": form, "page": article,
"turnstile_site_key": _turnstile_site_key(),
}
return _add_vary_header(render(request, "comments/_comment_form.html", ctx))
def _render_htmx_success(self, request, article, comment):
"""Return fresh form + OOB-appended comment (if approved)."""
tsk = _turnstile_site_key()
oob_html = ""
if comment.is_approved:
ctx = _comment_template_context(comment, article, request)
if comment.parent_id:
# _reply.html expects 'reply' context key
reply_ctx = ctx.copy()
reply_ctx["reply"] = reply_ctx.pop("comment")
comment_html = render_to_string("comments/_reply.html", reply_ctx, request)
# .replies-container is now a sibling of #comment-{id}
oob_html = (
f'<div hx-swap-oob="beforeend:#comment-{comment.parent_id} '
f'~ .replies-container">{comment_html}</div>'
)
else:
comment_html = render_to_string("comments/_comment.html", ctx, request)
oob_html = (
f'<div hx-swap-oob="beforeend:#comments-list">'
f"{comment_html}</div>"
)
if comment.parent_id:
parent = Comment.objects.filter(pk=comment.parent_id, article=article).first()
msg = "Reply posted!" if comment.is_approved else "Your reply is awaiting moderation."
form_html = render_to_string("comments/_reply_form.html", {
"comment": parent, "page": article,
"turnstile_site_key": tsk, "reply_success_message": msg,
}, request)
else:
msg = (
"Comment posted!" if comment.is_approved
else "Your comment has been posted and is awaiting moderation."
)
form_html = render_to_string("comments/_comment_form.html", {
"page": article, "turnstile_site_key": tsk, "success_message": msg,
}, request)
resp = HttpResponse(form_html + oob_html)
return _add_vary_header(resp)
def _render_article_with_errors(self, request, article, form):
context = article.get_context(request)
context["page"] = article
context["comment_form"] = form
return render(request, "blog/article_page.html", context, status=200)
def post(self, request):
ip = client_ip_from_request(request)
key = f"comment-rate:{ip}"
count = cache.get(key, 0)
rate_limit = getattr(settings, "COMMENT_RATE_LIMIT_PER_MINUTE", 3)
if count >= rate_limit:
if count >= 3:
return HttpResponse(status=429)
cache.set(key, count + 1, timeout=60)
@@ -199,21 +44,9 @@ class CommentCreateView(View):
if form.is_valid():
if form.cleaned_data.get("honeypot"):
if _is_htmx(request):
return _add_vary_header(
render(request, "comments/_comment_success.html", {"message": "Comment posted!"})
)
return redirect(f"{article.url}?commented=1")
# Turnstile verification
turnstile_ok = False
if _turnstile_enabled():
token = request.POST.get("cf-turnstile-response", "")
turnstile_ok = _verify_turnstile(token, ip)
comment = form.save(commit=False)
comment.article = article
comment.is_approved = turnstile_ok
parent_id = form.cleaned_data.get("parent_id")
if parent_id:
comment.parent = Comment.objects.filter(pk=parent_id, article=article).first()
@@ -222,100 +55,9 @@ class CommentCreateView(View):
comment.full_clean()
except ValidationError:
form.add_error(None, "Reply depth exceeds the allowed limit")
if _is_htmx(request):
return self._render_htmx_error(request, article, form)
context = article.get_context(request)
context.update({"page": article, "comment_form": form})
return render(request, "blog/article_page.html", context, status=200)
return self._render_article_with_errors(request, article, form)
comment.save()
if _is_htmx(request):
return self._render_htmx_success(request, article, comment)
messages.success(
request,
"Comment posted!" if comment.is_approved else "Your comment is awaiting moderation",
)
messages.success(request, "Your comment is awaiting moderation")
return redirect(f"{article.url}?commented=1")
if _is_htmx(request):
return self._render_htmx_error(request, article, form)
context = article.get_context(request)
context.update({"page": article, "comment_form": form})
return render(request, "blog/article_page.html", context, status=200)
@require_GET
def comment_poll(request, article_id):
"""Return comments newer than after_id for HTMX polling."""
article = get_object_or_404(ArticlePage, pk=article_id)
after_id = request.GET.get("after_id", "0")
try:
after_id = int(after_id)
except (ValueError, TypeError):
after_id = 0
approved_replies = Comment.objects.filter(is_approved=True).select_related("parent")
comments = list(
article.comments.filter(is_approved=True, parent__isnull=True, id__gt=after_id)
.prefetch_related(Prefetch("replies", queryset=approved_replies))
.order_by("created_at", "id")
)
_annotate_reaction_counts(comments, _get_session_key(request))
resp = render(request, "comments/_comment_list_inner.html", {
"approved_comments": comments,
"page": article,
"turnstile_site_key": _turnstile_site_key(),
})
return _add_vary_header(resp)
@require_POST
def comment_react(request, comment_id):
"""Toggle a reaction on a comment."""
ip = client_ip_from_request(request)
key = f"reaction-rate:{ip}"
count = cache.get(key, 0)
rate_limit = getattr(settings, "REACTION_RATE_LIMIT_PER_MINUTE", 20)
if count >= rate_limit:
return HttpResponse(status=429)
cache.set(key, count + 1, timeout=60)
comment = get_object_or_404(Comment, pk=comment_id, is_approved=True)
reaction_type = request.POST.get("reaction_type", "heart")
if reaction_type not in ("heart", "plus_one"):
return HttpResponse(status=400)
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
try:
existing = CommentReaction.objects.filter(
comment=comment, reaction_type=reaction_type, session_key=session_key
).first()
if existing:
existing.delete()
else:
CommentReaction.objects.create(
comment=comment, reaction_type=reaction_type, session_key=session_key
)
except IntegrityError:
pass
counts = {}
for rt in ("heart", "plus_one"):
counts[rt] = comment.reactions.filter(reaction_type=rt).count()
user_reacted = set(
comment.reactions.filter(session_key=session_key).values_list("reaction_type", flat=True)
)
if _is_htmx(request):
resp = render(request, "comments/_reactions.html", {
"comment": comment, "counts": counts, "user_reacted": user_reacted,
})
return _add_vary_header(resp)
return JsonResponse({"counts": counts, "user_reacted": list(user_reacted)})
return self._render_article_with_errors(request, article, form)

View File

@@ -4,7 +4,7 @@ from django.db.models import Count, Q
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext
from wagtail import hooks
from wagtail.admin.ui.tables import BooleanColumn, Column
from wagtail.admin.ui.tables import BooleanColumn
from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
from wagtail.snippets.models import register_snippet
from wagtail.snippets.permissions import get_permission_name
@@ -41,45 +41,11 @@ class ApproveCommentBulkAction(SnippetBulkAction):
) % {"count": num_parent_objects}
class UnapproveCommentBulkAction(SnippetBulkAction):
display_name = _("Unapprove")
action_type = "unapprove"
aria_label = _("Unapprove selected comments")
template_name = "comments/confirm_bulk_unapprove.html"
action_priority = 30
models = [Comment]
def check_perm(self, snippet):
if getattr(self, "can_change_items", None) is None:
self.can_change_items = self.request.user.has_perm(get_permission_name("change", self.model))
return self.can_change_items
@classmethod
def execute_action(cls, objects, **kwargs):
updated = kwargs["self"].model.objects.filter(pk__in=[obj.pk for obj in objects], is_approved=True).update(
is_approved=False
)
return updated, 0
def get_success_message(self, num_parent_objects, num_child_objects):
return ngettext(
"%(count)d comment unapproved.",
"%(count)d comments unapproved.",
num_parent_objects,
) % {"count": num_parent_objects}
class CommentViewSet(SnippetViewSet):
model = Comment
queryset = Comment.objects.all()
icon = "comment"
list_display = [
"author_name",
"article",
BooleanColumn("is_approved"),
Column("pending_in_article", label="Pending (article)"),
"created_at",
]
list_display = ["author_name", "article", BooleanColumn("is_approved"), "pending_in_article", "created_at"]
list_filter = ["is_approved"]
search_fields = ["author_name", "body"]
add_to_admin_menu = True
@@ -96,6 +62,11 @@ class CommentViewSet(SnippetViewSet):
)
)
def pending_in_article(self, obj):
return obj.pending_in_article
pending_in_article.short_description = "Pending (article)" # type: ignore[attr-defined]
register_snippet(CommentViewSet)
hooks.register("register_bulk_action", ApproveCommentBulkAction)
hooks.register("register_bulk_action", UnapproveCommentBulkAction)

View File

@@ -1,4 +1,3 @@
from django.conf import settings as django_settings
from wagtail.models import Site
from apps.core.models import SiteSettings
@@ -7,7 +6,4 @@ from apps.core.models import SiteSettings
def site_settings(request):
site = Site.find_for_request(request)
settings_obj = SiteSettings.for_site(site) if site else None
return {
"site_settings": settings_obj,
"turnstile_site_key": getattr(django_settings, "TURNSTILE_SITE_KEY", ""),
}
return {"site_settings": settings_obj}

View File

@@ -1,20 +1,13 @@
from __future__ import annotations
import os
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from taggit.models import Tag
from wagtail.models import Page, Site
from apps.authors.models import Author
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
from apps.comments.models import Comment
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
from apps.legal.models import LegalIndexPage, LegalPage
User = get_user_model()
class Command(BaseCommand):
help = "Seed deterministic content for E2E checks."
@@ -22,8 +15,6 @@ class Command(BaseCommand):
def handle(self, *args, **options):
import datetime
from django.utils import timezone
root = Page.get_first_root_node()
home = HomePage.objects.child_of(root).first()
@@ -47,9 +38,6 @@ class Command(BaseCommand):
)
# Primary article — comments enabled, used by nightly journey test
# published_date is set explicitly to ensure deterministic ordering
# (most recent first) so this article appears at the top of listings.
now = timezone.now()
article = ArticlePage.objects.child_of(article_index).filter(slug="nightly-playwright-journey").first()
if article is None:
article = ArticlePage(
@@ -59,24 +47,11 @@ class Command(BaseCommand):
summary="Seeded article for nightly browser journey.",
body=[("rich_text", "<p>Seeded article body for nightly browser checks.</p>")],
comments_enabled=True,
published_date=now,
)
article_index.add_child(instance=article)
article.save_revision().publish()
# Ensure deterministic ordering — primary article always newest
ArticlePage.objects.filter(pk=article.pk).update(published_date=now)
# Seed one approved top-level comment on the primary article for reply E2E tests
if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists():
Comment.objects.create(
article=article,
author_name="E2E Approved Commenter",
author_email="approved@example.com",
body="This is a seeded approved comment for reply testing.",
is_approved=True,
)
# Tagged article — used by tag-filter E2E tests
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
@@ -88,13 +63,9 @@ class Command(BaseCommand):
summary="An article with tags for E2E filter tests.",
body=[("rich_text", "<p>This article is tagged with AI Tools.</p>")],
comments_enabled=True,
published_date=now - datetime.timedelta(hours=1),
)
article_index.add_child(instance=tagged_article)
tagged_article.save_revision().publish()
ArticlePage.objects.filter(pk=tagged_article.pk).update(
published_date=now - datetime.timedelta(hours=1)
)
tagged_article.tags.add(tag)
tagged_article.save()
@@ -108,7 +79,6 @@ class Command(BaseCommand):
summary="An article with comments disabled.",
body=[("rich_text", "<p>Comments are disabled on this one.</p>")],
comments_enabled=False,
published_date=now - datetime.timedelta(hours=2),
)
article_index.add_child(instance=no_comments_article)
# Explicitly persist False after add_child (which internally calls save())
@@ -116,9 +86,6 @@ class Command(BaseCommand):
ArticlePage.objects.filter(pk=no_comments_article.pk).update(comments_enabled=False)
no_comments_article.comments_enabled = False
no_comments_article.save_revision().publish()
ArticlePage.objects.filter(pk=no_comments_article.pk).update(
published_date=now - datetime.timedelta(hours=2)
)
# About page
if not AboutPage.objects.child_of(home).filter(slug="about").exists():
@@ -161,53 +128,4 @@ class Command(BaseCommand):
site.is_default_site = True
site.save()
# Navigation menu items and social links — always reconcile to
# match the pages we just created (the data migration may have
# seeded partial items before these pages existed).
settings, _ = SiteSettings.objects.get_or_create(site=site)
NavigationMenuItem.objects.filter(settings=settings).delete()
article_index_page = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
about_page = AboutPage.objects.child_of(home).filter(slug="about").first()
nav_items = [
NavigationMenuItem(settings=settings, link_page=home, link_title="Home", sort_order=0),
]
if article_index_page:
nav_items.append(
NavigationMenuItem(
settings=settings, link_page=article_index_page,
link_title="Articles", sort_order=1,
)
)
if about_page:
nav_items.append(
NavigationMenuItem(
settings=settings, link_page=about_page,
link_title="About", sort_order=2,
)
)
NavigationMenuItem.objects.bulk_create(nav_items)
SocialMediaLink.objects.filter(settings=settings).delete()
SocialMediaLink.objects.bulk_create(
[
SocialMediaLink(
settings=settings, platform="twitter",
url="https://twitter.com/nohypeai",
label="Twitter (X)", sort_order=0,
),
SocialMediaLink(
settings=settings, platform="rss",
url="/feed/", label="RSS Feed", sort_order=1,
),
]
)
# Admin user for E2E admin tests — only when E2E_MODE is set
if os.environ.get("E2E_MODE") and not User.objects.filter(username="e2e-admin").exists():
User.objects.create_superuser(
username="e2e-admin",
email="e2e-admin@example.com",
password="e2e-admin-pass",
)
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))

View File

@@ -18,22 +18,17 @@ class SecurityHeadersMiddleware:
def __init__(self, get_response):
self.get_response = get_response
ADMIN_PREFIXES = ("/cms/", "/django-admin/")
def __call__(self, request):
nonce = secrets.token_urlsafe(16)
request.csp_nonce = nonce
response = self.get_response(request)
if request.path.startswith(self.ADMIN_PREFIXES):
return response
response["Content-Security-Policy"] = (
f"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' https://challenges.cloudflare.com; "
"style-src 'self' https://fonts.googleapis.com; "
f"script-src 'self' 'nonce-{nonce}'; "
"style-src 'self'; "
"img-src 'self' data: blob:; "
"font-src 'self' https://fonts.gstatic.com; "
"connect-src 'self' https://challenges.cloudflare.com; "
"frame-src https://challenges.cloudflare.com; "
"font-src 'self'; "
"connect-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"frame-ancestors 'self'"

View File

@@ -1,69 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-02 18:39
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('wagtailcore', '0094_alter_page_locale'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='copyright_text',
field=models.CharField(default='No Hype AI. All rights reserved.', max_length=200),
),
migrations.AddField(
model_name='sitesettings',
name='footer_description',
field=models.TextField(blank=True, default='In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.'),
),
migrations.AddField(
model_name='sitesettings',
name='site_name',
field=models.CharField(default='NO HYPE AI', max_length=100),
),
migrations.AddField(
model_name='sitesettings',
name='tagline',
field=models.CharField(default='Honest AI tool reviews for developers.', max_length=200),
),
migrations.CreateModel(
name='NavigationMenuItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('link_url', models.URLField(blank=True, default='', help_text='External URL (used only when no page is selected).')),
('link_title', models.CharField(blank=True, default='', help_text='Override the display text. If blank, the page title is used.', max_length=100)),
('open_in_new_tab', models.BooleanField(default=False)),
('show_in_header', models.BooleanField(default=True)),
('show_in_footer', models.BooleanField(default=True)),
('link_page', models.ForeignKey(blank=True, help_text='Link to an internal page. If unpublished, the link is hidden automatically.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='navigation_items', to='core.sitesettings')),
],
options={
'ordering': ['sort_order'],
'abstract': False,
},
),
migrations.CreateModel(
name='SocialMediaLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('platform', models.CharField(choices=[('twitter', 'Twitter / X'), ('github', 'GitHub'), ('rss', 'RSS Feed'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube'), ('mastodon', 'Mastodon'), ('bluesky', 'Bluesky')], max_length=30)),
('url', models.URLField()),
('label', models.CharField(blank=True, default='', help_text='Display label. If blank, the platform name is used.', max_length=100)),
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_links', to='core.sitesettings')),
],
options={
'ordering': ['sort_order'],
'abstract': False,
},
),
]

View File

@@ -1,105 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-02 18:39
from django.db import migrations
def seed_navigation_data(apps, schema_editor):
Site = apps.get_model("wagtailcore", "Site")
SiteSettings = apps.get_model("core", "SiteSettings")
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
Page = apps.get_model("wagtailcore", "Page")
for site in Site.objects.all():
settings, _ = SiteSettings.objects.get_or_create(site=site)
# Only seed if no nav items exist yet
if NavigationMenuItem.objects.filter(settings=settings).exists():
continue
root_page = site.root_page
if not root_page:
continue
# Find pages by slug under the site root using tree path
home_page = root_page
# In Wagtail's treebeard, direct children share the root's path prefix
articles_page = Page.objects.filter(
depth=root_page.depth + 1,
path__startswith=root_page.path,
slug__startswith="articles",
).first()
about_page = Page.objects.filter(
depth=root_page.depth + 1,
path__startswith=root_page.path,
slug__startswith="about",
).first()
nav_items = []
if home_page:
nav_items.append(
NavigationMenuItem(
settings=settings,
link_page=home_page,
link_title="Home",
show_in_header=True,
show_in_footer=True,
sort_order=0,
)
)
if articles_page:
nav_items.append(
NavigationMenuItem(
settings=settings,
link_page=articles_page,
link_title="Articles",
show_in_header=True,
show_in_footer=True,
sort_order=1,
)
)
if about_page:
nav_items.append(
NavigationMenuItem(
settings=settings,
link_page=about_page,
link_title="About",
show_in_header=True,
show_in_footer=True,
sort_order=2,
)
)
NavigationMenuItem.objects.bulk_create(nav_items)
# Social links
if not SocialMediaLink.objects.filter(settings=settings).exists():
SocialMediaLink.objects.bulk_create(
[
SocialMediaLink(
settings=settings,
platform="twitter",
url="https://twitter.com/nohypeai",
label="Twitter (X)",
sort_order=0,
),
SocialMediaLink(
settings=settings,
platform="rss",
url="/feed/",
label="RSS Feed",
sort_order=1,
),
]
)
class Migration(migrations.Migration):
dependencies = [
('core', '0002_sitesettings_copyright_text_and_more'),
('wagtailcore', '0094_alter_page_locale'),
]
operations = [
migrations.RunPython(seed_navigation_data, migrations.RunPython.noop),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.2.11 on 2026-03-02 19:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_seed_navigation_data'),
]
operations = [
migrations.AlterField(
model_name='navigationmenuitem',
name='link_url',
field=models.CharField(blank=True, default='', help_text='URL or path (used only when no page is selected).', max_length=500),
),
migrations.AlterField(
model_name='socialmedialink',
name='url',
field=models.CharField(help_text='URL or path (e.g. https://twitter.com/… or /feed/).', max_length=500),
),
]

View File

@@ -1,24 +1,10 @@
from django.db import models
from django.db.models import SET_NULL
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
from wagtail.models import Orderable
SOCIAL_ICON_CHOICES = [
("twitter", "Twitter / X"),
("github", "GitHub"),
("rss", "RSS Feed"),
("linkedin", "LinkedIn"),
("youtube", "YouTube"),
("mastodon", "Mastodon"),
("bluesky", "Bluesky"),
]
@register_setting
class SiteSettings(ClusterableModel, BaseSiteSetting):
class SiteSettings(BaseSiteSetting):
default_og_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@@ -33,141 +19,3 @@ class SiteSettings(ClusterableModel, BaseSiteSetting):
on_delete=SET_NULL,
related_name="+",
)
# Branding
site_name = models.CharField(max_length=100, default="NO HYPE AI")
tagline = models.CharField(
max_length=200,
default="Honest AI tool reviews for developers.",
)
footer_description = models.TextField(
default="In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.",
blank=True,
)
copyright_text = models.CharField(
max_length=200,
default="No Hype AI. All rights reserved.",
)
panels = [
MultiFieldPanel(
[
FieldPanel("site_name"),
FieldPanel("tagline"),
FieldPanel("footer_description"),
FieldPanel("copyright_text"),
],
heading="Branding",
),
MultiFieldPanel(
[
FieldPanel("default_og_image"),
FieldPanel("privacy_policy_page"),
],
heading="SEO & Legal",
),
InlinePanel("navigation_items", label="Navigation Menu Items"),
InlinePanel("social_links", label="Social Media Links"),
]
class NavigationMenuItem(Orderable):
settings = ParentalKey(
SiteSettings,
on_delete=models.CASCADE,
related_name="navigation_items",
)
link_page = models.ForeignKey(
"wagtailcore.Page",
null=True,
blank=True,
on_delete=SET_NULL,
related_name="+",
help_text="Link to an internal page. If unpublished, the link is hidden automatically.",
)
link_url = models.CharField(
max_length=500,
blank=True,
default="",
help_text="URL or path (used only when no page is selected).",
)
link_title = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Override the display text. If blank, the page title is used.",
)
open_in_new_tab = models.BooleanField(default=False)
show_in_header = models.BooleanField(default=True)
show_in_footer = models.BooleanField(default=True)
panels = [
FieldPanel("link_page"),
FieldPanel("link_url"),
FieldPanel("link_title"),
FieldPanel("open_in_new_tab"),
FieldPanel("show_in_header"),
FieldPanel("show_in_footer"),
]
@property
def title(self):
if self.link_title:
return self.link_title
if self.link_page:
return self.link_page.title
return ""
@property
def url(self):
if self.link_page:
return self.link_page.url
return self.link_url
@property
def is_live(self):
"""Return False if linked to an unpublished/non-live page."""
if self.link_page_id:
return self.link_page.live
return bool(self.link_url)
class Meta(Orderable.Meta):
pass
class SocialMediaLink(Orderable):
settings = ParentalKey(
SiteSettings,
on_delete=models.CASCADE,
related_name="social_links",
)
platform = models.CharField(
max_length=30,
choices=SOCIAL_ICON_CHOICES,
)
url = models.CharField(max_length=500, help_text="URL or path (e.g. https://twitter.com/… or /feed/).")
label = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Display label. If blank, the platform name is used.",
)
panels = [
FieldPanel("platform"),
FieldPanel("url"),
FieldPanel("label"),
]
@property
def display_label(self):
if self.label:
return self.label
return dict(SOCIAL_ICON_CHOICES).get(self.platform, self.platform)
@property
def icon_template(self):
return f"components/icons/{self.platform}.html"
class Meta(Orderable.Meta):
pass

View File

@@ -4,8 +4,7 @@ from django import template
from django.utils.safestring import mark_safe
from wagtail.models import Site
from apps.blog.models import ArticleIndexPage, Category, TagMetadata
from apps.core.models import SiteSettings
from apps.blog.models import TagMetadata
from apps.legal.models import LegalPage
register = template.Library()
@@ -21,55 +20,6 @@ def get_legal_pages(context):
return pages
@register.simple_tag(takes_context=True)
def get_nav_items(context, location="header"):
request = context.get("request")
site = Site.find_for_request(request) if request else None
settings = SiteSettings.for_site(site) if site else None
if not settings:
return []
items = settings.navigation_items.all()
if location == "header":
items = items.filter(show_in_header=True)
elif location == "footer":
items = items.filter(show_in_footer=True)
return [item for item in items if item.is_live]
@register.simple_tag(takes_context=True)
def get_social_links(context):
request = context.get("request")
site = Site.find_for_request(request) if request else None
settings = SiteSettings.for_site(site) if site else None
if not settings:
return []
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.filter
def get_tag_css(tag):
@@ -78,12 +28,3 @@ def get_tag_css(tag):
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
return mark_safe(f"{classes['bg']} {classes['text']}")
@register.filter
def get_tag_border_css(tag):
meta = getattr(tag, "metadata", None)
if meta is None:
meta = TagMetadata.objects.filter(tag=tag).first()
classes = meta.get_css_classes() if meta else TagMetadata.get_fallback_css()
return mark_safe(classes.get("border", ""))

View File

@@ -27,13 +27,6 @@ def test_get_tag_css_fallback():
assert "bg-zinc" in value
@pytest.mark.django_db
def test_get_tag_border_css_fallback():
tag = Tag.objects.create(name="y", slug="y")
value = core_tags.get_tag_border_css(tag)
assert "border-zinc" in value
@pytest.mark.django_db
def test_get_legal_pages_tag_callable(home_page):
legal_index = LegalIndexPage(title="Legal", slug="legal")

View File

@@ -1,191 +0,0 @@
import pytest
from wagtail.models import Site
from apps.blog.models import AboutPage, ArticleIndexPage
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
@pytest.fixture
def site_with_nav(home_page):
"""Create SiteSettings with nav items and social links for testing."""
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
# Clear any items seeded by the data migration
settings.navigation_items.all().delete()
settings.social_links.all().delete()
# Create article index and about page
article_index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=article_index)
article_index.save_revision().publish()
about = AboutPage(
title="About",
slug="about",
mission_statement="Test mission",
body="<p>About page</p>",
)
home_page.add_child(instance=about)
about.save_revision().publish()
# Create nav items
NavigationMenuItem.objects.create(
settings=settings,
link_page=home_page,
link_title="Home",
show_in_header=True,
show_in_footer=True,
sort_order=0,
)
NavigationMenuItem.objects.create(
settings=settings,
link_page=article_index,
link_title="Articles",
show_in_header=True,
show_in_footer=True,
sort_order=1,
)
NavigationMenuItem.objects.create(
settings=settings,
link_page=about,
link_title="About",
show_in_header=True,
show_in_footer=False,
sort_order=2,
)
# Social links
SocialMediaLink.objects.create(
settings=settings,
platform="twitter",
url="https://twitter.com/nohypeai",
label="Twitter (X)",
sort_order=0,
)
SocialMediaLink.objects.create(
settings=settings,
platform="rss",
url="/feed/",
label="RSS Feed",
sort_order=1,
)
return settings
@pytest.mark.django_db
class TestNavigationMenuItem:
def test_live_page_is_rendered(self, site_with_nav):
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
assert len(items) == 3
def test_unpublished_page_excluded(self, site_with_nav):
about_item = site_with_nav.navigation_items.get(link_title="About")
about_item.link_page.unpublish()
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
assert len(items) == 2
assert all(i.link_title != "About" for i in items)
def test_external_url_item(self, site_with_nav):
NavigationMenuItem.objects.create(
settings=site_with_nav,
link_url="https://example.com",
link_title="External",
sort_order=10,
)
item = site_with_nav.navigation_items.get(link_title="External")
assert item.is_live is True
assert item.url == "https://example.com"
assert item.title == "External"
def test_title_falls_back_to_page_title(self, site_with_nav):
item = site_with_nav.navigation_items.get(sort_order=0)
item.link_title = ""
item.save()
assert item.title == item.link_page.title
def test_header_footer_filtering(self, site_with_nav):
header_items = site_with_nav.navigation_items.filter(show_in_header=True)
footer_items = site_with_nav.navigation_items.filter(show_in_footer=True)
assert header_items.count() == 3
assert footer_items.count() == 2 # About excluded from footer
def test_sort_order_respected(self, site_with_nav):
items = list(site_with_nav.navigation_items.all().order_by("sort_order"))
assert [i.link_title for i in items] == ["Home", "Articles", "About"]
@pytest.mark.django_db
class TestSocialMediaLink:
def test_display_label_from_field(self, site_with_nav):
link = site_with_nav.social_links.get(platform="twitter")
assert link.display_label == "Twitter (X)"
def test_display_label_fallback(self, site_with_nav):
link = site_with_nav.social_links.get(platform="twitter")
link.label = ""
assert link.display_label == "Twitter / X"
def test_icon_template_path(self, site_with_nav):
link = site_with_nav.social_links.get(platform="rss")
assert link.icon_template == "components/icons/rss.html"
def test_ordering(self, site_with_nav):
links = list(site_with_nav.social_links.all().order_by("sort_order"))
assert [link.platform for link in links] == ["twitter", "rss"]
@pytest.mark.django_db
class TestSiteSettingsDefaults:
def test_default_site_name(self, home_page):
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
assert settings.site_name == "NO HYPE AI"
def test_default_copyright(self, home_page):
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
assert settings.copyright_text == "No Hype AI. All rights reserved."
def test_default_tagline(self, home_page):
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
assert settings.tagline == "Honest AI tool reviews for developers."
@pytest.mark.django_db
class TestNavRendering:
def test_header_shows_nav_items(self, client, site_with_nav):
resp = client.get("/")
content = resp.content.decode()
assert "Home" in content
assert "Articles" in content
assert "About" in content
def test_unpublished_page_not_in_header(self, client, site_with_nav):
about_item = site_with_nav.navigation_items.get(link_title="About")
about_item.link_page.unpublish()
resp = client.get("/")
content = resp.content.decode()
# About should not appear as a nav link (but might appear elsewhere on page)
assert 'href="/about/"' not in content
def test_footer_shows_nav_items(self, client, site_with_nav):
resp = client.get("/")
content = resp.content.decode()
# Footer should have social links
assert "Twitter (X)" in content
assert "RSS Feed" in content
def test_footer_shows_branding(self, client, site_with_nav):
site_with_nav.site_name = "TEST SITE"
site_with_nav.save()
resp = client.get("/")
content = resp.content.decode()
assert "TEST SITE" in content
def test_footer_shows_copyright(self, client, site_with_nav):
resp = client.get("/")
content = resp.content.decode()
assert "No Hype AI. All rights reserved." in content

View File

@@ -32,7 +32,7 @@ def test_nightly_playwright_journey() -> None:
article_url = article_href if article_href.startswith("http") else f"{base_url}{article_href}"
page.goto(article_url, wait_until="networkidle")
expect(page.get_by_role("heading", name="Comments", exact=True)).to_be_visible()
expect(page.get_by_role("heading", name="Comments")).to_be_visible()
expect(page.get_by_role("button", name="Post comment")).to_be_visible()
page.goto(f"{base_url}/feed/", wait_until="networkidle")

View File

@@ -1,7 +1,5 @@
import pytest
from apps.blog.models import ArticleIndexPage, ArticlePage, Category
from apps.blog.tests.factories import AuthorFactory
from apps.legal.models import LegalIndexPage, LegalPage
@@ -15,36 +13,3 @@ 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()
@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

@@ -29,7 +29,6 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sitemaps",
"django.contrib.postgres",
"taggit",
"modelcluster",
"wagtail.contrib.forms",
@@ -48,7 +47,6 @@ INSTALLED_APPS = [
"wagtailseo",
"tailwind",
"theme",
"django_htmx",
"apps.core",
"apps.blog",
"apps.authors",
@@ -67,7 +65,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
"apps.core.middleware.ConsentMiddleware",
]
@@ -145,25 +142,6 @@ X_CONTENT_TYPE_OPTIONS = "nosniff"
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
TAILWIND_APP_NAME = "theme"
# Cloudflare Turnstile (comment spam protection)
TURNSTILE_SITE_KEY = os.getenv("TURNSTILE_SITE_KEY", "")
TURNSTILE_SECRET_KEY = os.getenv("TURNSTILE_SECRET_KEY", "")
TURNSTILE_EXPECTED_HOSTNAME = os.getenv("TURNSTILE_EXPECTED_HOSTNAME", "")
WAGTAILSEARCH_BACKENDS = {
"default": {
"BACKEND": "wagtail.search.backends.database",
"SEARCH_CONFIG": "english",
}
}

View File

@@ -4,20 +4,10 @@ DEBUG = True
INTERNAL_IPS = ["127.0.0.1"]
# Drop WhiteNoise in dev — it serves from STATIC_ROOT which is empty without
# collectstatic, so it 404s every asset. Django's runserver serves static and
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
# media URL pattern in urls.py).
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
# Use plain static file storage in dev — CompressedManifestStaticFilesStorage
# (set in base.py) requires collectstatic to have been run and will 404 on
# every asset otherwise.
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
try:
import debug_toolbar # noqa: F401
@@ -26,5 +16,3 @@ try:
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
except Exception:
pass
COMMENT_RATE_LIMIT_PER_MINUTE = 100

View File

@@ -2,16 +2,8 @@ from .base import * # noqa
DEBUG = False
# Behind Caddy: trust the forwarded proto header so Django knows it's HTTPS.
# SECURE_SSL_REDIRECT is intentionally off — Caddy handles HTTPS redirects
# before the request reaches Django; enabling it here causes redirect loops.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SECURE_SSL_REDIRECT = False
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = [
"https://nohypeai.net",
"https://www.nohypeai.net",
]

View File

@@ -1,13 +1,10 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
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, CategoryArticlesFeed, TagArticlesFeed
from apps.blog.views import search as search_view
from apps.blog.feeds import AllArticlesFeed, TagArticlesFeed
from apps.core.views import consent_view, robots_txt
urlpatterns = [
@@ -19,13 +16,8 @@ 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)),
path("search/", search_view, name="search"),
path("", include(wagtail_urls)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,23 +0,0 @@
nohypeai.net, www.nohypeai.net {
encode gzip zstd
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "geolocation=(), microphone=(), camera=()"
X-Forwarded-Proto https
}
handle_path /static/* {
root * /srv/sum/nohype/static
file_server
}
handle_path /media/* {
root * /srv/sum/nohype/media
file_server
}
reverse_proxy localhost:8001
}

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Deploy script for No Hype AI — runs on lintel-prod-01 as deploy user.
# Called by CI after a successful push to main.
set -euo pipefail
SITE_DIR=/srv/sum/nohype
APP_DIR=${SITE_DIR}/app
cd "${SITE_DIR}"
echo "==> Pulling latest code"
git -C "${APP_DIR}" pull origin main
echo "==> Updating compose file"
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
echo "==> Ensuring static/media directories exist"
mkdir -p "${SITE_DIR}/static" "${SITE_DIR}/media"
echo "==> Rebuilding and recreating web container"
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build --force-recreate web
echo "==> Waiting for health check"
for i in $(seq 1 30); do
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
echo "==> Site is up"
exit 0
fi
sleep 3
done
echo "ERROR: site did not come up after 90s" >&2
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" logs --tail=50 web
exit 1

View File

@@ -1,23 +0,0 @@
#!/bin/sh
set -e
python manage.py tailwind install --no-input
python manage.py tailwind build
python manage.py migrate --noinput
python manage.py collectstatic --noinput
python manage.py update_index
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
python manage.py shell -c "
from wagtail.models import Site
import os
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
"
exec gunicorn config.wsgi:application \
--workers 3 \
--bind 0.0.0.0:8000 \
--access-logfile - \
--error-logfile - \
--capture-output

View File

@@ -1,26 +0,0 @@
[Unit]
Description=No Hype AI (Docker Compose)
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=simple
User=deploy
Group=www-data
WorkingDirectory=/srv/sum/nohype
ExecStartPre=docker compose -f docker-compose.prod.yml pull --ignore-pull-failures
ExecStart=docker compose -f docker-compose.prod.yml up --build
ExecStop=docker compose -f docker-compose.prod.yml down
Restart=always
RestartSec=10
TimeoutStartSec=300
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=sum-nohype
[Install]
WantedBy=multi-user.target

View File

@@ -1,36 +0,0 @@
services:
web:
build: app
working_dir: /app
command: /app/deploy/entrypoint.prod.sh
env_file: .env
environment:
DJANGO_SETTINGS_MODULE: config.settings.production
volumes:
- /srv/sum/nohype/static:/app/staticfiles
- /srv/sum/nohype/media:/app/media
ports:
- "127.0.0.1:8001:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
env_file: .env
environment:
POSTGRES_DB: nohype
POSTGRES_USER: nohype
volumes:
- nohype_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
volumes:
nohype_pg:

View File

@@ -3,9 +3,7 @@ services:
build: .
working_dir: /app
command: >
sh -c "python manage.py tailwind install --no-input &&
python manage.py tailwind build &&
python manage.py migrate --noinput &&
sh -c "python manage.py migrate --noinput &&
python manage.py seed_e2e_content &&
python manage.py runserver 0.0.0.0:8000"
volumes:
@@ -24,7 +22,6 @@ services:
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL: hello@nohypeai.com
NEWSLETTER_PROVIDER: buttondown
E2E_MODE: "1"
depends_on:
db:
condition: service_healthy

View File

@@ -1,56 +0,0 @@
"""E2E tests for Wagtail admin editor experience improvements."""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
def admin_login(page: Page, base_url: str) -> None:
"""Log in to the Wagtail admin using the seeded E2E admin user."""
page.goto(f"{base_url}/cms/login/", wait_until="networkidle")
page.fill('input[name="username"]', "e2e-admin")
page.fill('input[name="password"]', "e2e-admin-pass")
page.click('button[type="submit"]')
page.wait_for_load_state("networkidle")
@pytest.mark.e2e
def test_articles_menu_item_visible(page: Page, base_url: str) -> None:
"""The admin sidebar should contain an 'Articles' menu item."""
admin_login(page, base_url)
sidebar = page.locator("#wagtail-sidebar")
articles_link = sidebar.get_by_role("link", name="Articles")
expect(articles_link).to_be_visible()
@pytest.mark.e2e
def test_articles_listing_page_loads(page: Page, base_url: str) -> None:
"""Clicking 'Articles' should load the articles listing with seeded articles."""
admin_login(page, base_url)
page.goto(f"{base_url}/cms/articles/", wait_until="networkidle")
expect(page.get_by_role("heading").first).to_be_visible()
# Seeded articles should appear
expect(page.get_by_text("Nightly Playwright Journey")).to_be_visible()
@pytest.mark.e2e
def test_dashboard_has_articles_panel(page: Page, base_url: str) -> None:
"""The admin dashboard should include the articles summary panel."""
admin_login(page, base_url)
page.goto(f"{base_url}/cms/", wait_until="networkidle")
expect(page.get_by_text("Articles overview")).to_be_visible()
@pytest.mark.e2e
def test_article_editor_has_tabs(page: Page, base_url: str) -> None:
"""The article editor should have Content, Metadata, Publishing, and SEO tabs."""
admin_login(page, base_url)
page.goto(f"{base_url}/cms/articles/", wait_until="networkidle")
# Click the first article title link to edit it
page.get_by_role("link", name="Nightly Playwright Journey").first.click()
page.wait_for_load_state("networkidle")
expect(page.get_by_role("tab", name="Content")).to_be_visible()
expect(page.get_by_role("tab", name="Metadata")).to_be_visible()
expect(page.get_by_role("tab", name="Publishing")).to_be_visible()
expect(page.get_by_role("tab", name="SEO")).to_be_visible()

View File

@@ -12,96 +12,39 @@ def _go_to_article(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None:
"""Fill and submit the main (non-reply) comment form."""
form = page.locator("form[data-comment-form]")
form.locator('input[name="author_name"]').fill(name)
form.locator('input[name="author_email"]').fill(email)
form.locator('textarea[name="body"]').fill(body)
@pytest.mark.e2e
def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
# Fill the main comment form (not a reply form)
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
form.locator('input[name="author_name"]').fill("E2E Tester")
form.locator('input[name="author_email"]').fill("e2e@example.com")
form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.")
form.get_by_role("button", name="Post comment").click()
@pytest.mark.e2e
def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None:
"""Successful comment submission must show the awaiting-moderation message."""
_go_to_article(page, base_url)
_submit_comment(page, body="This is a test comment from Playwright.")
# HTMX swaps the form container inline — wait for the moderation message
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
@pytest.mark.e2e
def test_valid_comment_not_immediately_visible(page: Page, base_url: str) -> None:
"""Submitted comment must NOT appear in the comments list before moderation."""
_go_to_article(page, base_url)
unique_body = "Unique unmoderated comment body xq7z"
_submit_comment(page, body=unique_body)
# Wait for HTMX response to settle
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
expect(page.get_by_text(unique_body)).not_to_be_visible()
# Successful submission redirects back to the article with ?commented=1
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
assert "commented=1" in page.url
@pytest.mark.e2e
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
_submit_comment(page, body=" ") # whitespace-only body
page.wait_for_load_state("networkidle")
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible(timeout=10_000)
assert "commented=1" not in page.url
@pytest.mark.e2e
def test_missing_name_shows_form_errors(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
form = page.locator("form[data-comment-form]")
form.locator('input[name="author_name"]').fill("")
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
form.locator('input[name="author_name"]').fill("E2E Tester")
form.locator('input[name="author_email"]').fill("e2e@example.com")
form.locator('textarea[name="body"]').fill("Comment without a name.")
form.locator('textarea[name="body"]').fill(" ") # whitespace-only body
form.get_by_role("button", name="Post comment").click()
page.wait_for_load_state("networkidle")
# The page re-renders with the error summary visible
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
# URL must NOT have ?commented=1 — form was not accepted
assert "commented=1" not in page.url
@pytest.mark.e2e
def test_reply_form_visible_on_approved_comment(page: Page, base_url: str) -> None:
"""An approved seeded comment must display a reply form."""
_go_to_article(page, base_url)
# The seeded approved comment should be visible (as author name)
expect(page.get_by_text("E2E Approved Commenter", exact=True)).to_be_visible()
# And a Reply toggle for it
expect(page.locator("summary").filter(has_text="Reply")).to_be_visible()
@pytest.mark.e2e
def test_reply_submission_shows_moderation_message(page: Page, base_url: str) -> None:
"""Submitting a reply to an approved comment should show moderation message."""
_go_to_article(page, base_url)
# Click the Reply toggle (summary element)
page.locator("summary").filter(has_text="Reply").first.click()
# The reply form should now be visible
post_reply_btn = page.get_by_test_id("post-reply-btn").first
expect(post_reply_btn).to_be_visible()
# Fill the form fields
# Use a locator that finds the container for this reply form (the details element)
reply_container = page.locator("details").filter(has=post_reply_btn).first
reply_container.locator('input[name="author_name"]').fill("E2E Replier")
reply_container.locator('input[name="author_email"]').fill("replier@example.com")
reply_container.locator('textarea[name="body"]').fill("This is a test reply.")
post_reply_btn.click()
# HTMX swaps the reply form container inline
expect(page.get_by_text("awaiting moderation")).to_be_visible(timeout=10_000)
@pytest.mark.e2e
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
"""Article with comments_enabled=False must not show the comments section."""
@@ -109,7 +52,8 @@ def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> Non
assert response is not None and response.status == 200, (
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
)
# Confirm we're on the right page
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
# Comments section must be absent — exact=True prevents matching "No Comments Article" h1
expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0)
expect(page.get_by_role("button", name="Post comment")).to_have_count(0)

View File

@@ -37,10 +37,12 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
@pytest.mark.e2e
def test_nav_search_box_present(page: Page, base_url: str) -> None:
def test_newsletter_form_in_nav(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle")
# The nav contains a newsletter form with an email input
nav = page.locator("nav")
expect(nav.locator('input[name="q"]')).to_be_visible()
expect(nav.locator('input[type="email"]')).to_be_visible()
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible()
@pytest.mark.e2e

View File

@@ -7,8 +7,7 @@ from playwright.sync_api import Page, expect
def _nav_newsletter_form(page: Page):
"""Return the newsletter form in the home page sidebar aside."""
return page.locator("aside").locator("form[data-newsletter-form]").first
return page.locator("nav").locator("form[data-newsletter-form]")
@pytest.mark.e2e
@@ -29,7 +28,7 @@ def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None:
form = _nav_newsletter_form(page)
# Disable the browser's native HTML5 email validation so the JS handler
# fires and sends the bad value to the server (which returns 400).
page.evaluate("document.querySelector('aside form[data-newsletter-form]').setAttribute('novalidate', '')")
page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')")
form.locator('input[type="email"]').fill("not-an-email")
form.get_by_role("button", name="Subscribe").click()

View File

@@ -28,10 +28,6 @@ ignore_missing_imports = true
module = ["apps.authors.models"]
ignore_errors = true
[[tool.mypy.overrides]]
module = ["apps.comments.views"]
ignore_errors = true
[tool.django-stubs]
django_settings_module = "config.settings.development"

View File

@@ -2,7 +2,7 @@ Django~=5.2.0
wagtail~=7.0.0
wagtail-seo~=3.1.1
psycopg2-binary~=2.9.0
Pillow~=12.1
Pillow~=11.0.0
django-taggit~=6.0.0
whitenoise~=6.0.0
gunicorn~=23.0.0
@@ -10,8 +10,6 @@ python-dotenv~=1.0.0
dj-database-url~=2.2.0
django-tailwind~=3.8.0
django-csp~=3.8.0
django-htmx~=1.21.0
requests~=2.32.0
pytest~=8.3.0
pytest-django~=4.9.0
pytest-cov~=5.0.0

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#09090b"/>
<text x="16" y="24" text-anchor="middle" font-family="'Space Grotesk',sans-serif" font-weight="700" font-size="22" fill="#fafafa">/</text>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

File diff suppressed because one or more lines are too long

View File

@@ -6,10 +6,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}No Hype AI{% endblock %}</title>
{% block head_meta %}{% endblock %}
<link rel="icon" href="{% static 'favicon.svg' %}" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/styles.css' %}" />
<script nonce="{{ request.csp_nonce|default:'' }}">
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
@@ -18,21 +14,18 @@
<script src="{% static 'js/theme.js' %}" defer></script>
<script src="{% static 'js/prism.js' %}" defer></script>
<script src="{% static 'js/newsletter.js' %}" defer></script>
<script src="{% static 'js/htmx.min.js' %}" nonce="{{ request.csp_nonce|default:'' }}" defer></script>
{% if turnstile_site_key %}<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer nonce="{{ request.csp_nonce|default:'' }}"></script>{% endif %}
</head>
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
<body>
{% include 'components/nav.html' %}
{% include 'components/cookie_banner.html' %}
{% if messages %}
<section aria-label="Messages" class="max-w-7xl mx-auto px-6 py-2">
<section aria-label="Messages">
{% for message in messages %}
<p class="font-mono text-sm py-2 px-4 bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20 mb-2">{{ message }}</p>
<p>{{ message }}</p>
{% endfor %}
</section>
{% endif %}
<main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main>
<main>{% block content %}{% endblock %}</main>
{% include 'components/footer.html' %}
</body>
</html>

View File

@@ -1,40 +1,14 @@
{% extends 'base.html' %}
{% load wagtailimages_tags wagtailcore_tags %}
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
{% block content %}
<!-- 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-4">{{ page.title }}</h1>
{% if page.mission_statement %}
<p class="text-xl md:text-2xl text-zinc-600 dark:text-zinc-400 font-medium max-w-2xl">{{ page.mission_statement }}</p>
{% endif %}
</div>
<!-- Body -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<div class="lg:col-span-8 prose prose-lg dark:prose-invert max-w-none
prose-headings:font-display prose-headings:font-bold
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline">
{{ page.body|richtext }}
</div>
{% if page.featured_author %}
<aside class="lg:col-span-4">
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-brand-cyan to-brand-pink"></div>
<h1>{{ page.title }}</h1>
<p>{{ page.mission_statement }}</p>
{{ page.body|richtext }}
{% if page.featured_author %}
<h2>{{ page.featured_author.name }}</h2>
<p>{{ page.featured_author.bio }}</p>
{% if page.featured_author.avatar %}
<div class="w-20 h-20 mb-4 overflow-hidden border border-zinc-200 dark:border-zinc-800">
{% image page.featured_author.avatar fill-80x80 class="w-full h-full object-cover" %}
</div>
{% else %}
<div class="w-20 h-20 mb-4 bg-gradient-to-tr from-brand-cyan to-brand-pink"></div>
{% image page.featured_author.avatar fill-200x200 %}
{% endif %}
<h2 class="font-display font-bold text-xl mb-1">{{ page.featured_author.name }}</h2>
{% if page.featured_author.role %}<p class="font-mono text-xs text-zinc-500 mb-3">{{ page.featured_author.role }}</p>{% endif %}
{% if page.featured_author.bio %}<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ page.featured_author.bio }}</p>{% endif %}
</div>
</aside>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@@ -10,63 +10,26 @@
<meta property="og:url" content="{{ canonical }}" />
{% endblock %}
{% block content %}
<!-- Page Header -->
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
{% 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 %}
<!-- Filters / Search -->
<div class="flex flex-col md:flex-row justify-between gap-6 mb-4">
<!-- Category Filters -->
<div class="flex flex-wrap gap-3">
<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>
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-64">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-10 pr-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</form>
</div>
<!-- Tag Filters -->
<div class="flex flex-wrap gap-3">
<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>
<h1>{{ page.title }}</h1>
<section>
<h2>Filter by tag</h2>
<a href="/articles/" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
{% for tag in available_tags %}
<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>
<a href="/articles/?tag={{ tag.slug }}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
{% endfor %}
</div>
</div>
<!-- Article List -->
<div class="space-y-8">
{% for article in articles %}
</section>
{% for article in articles %}
{% include 'components/article_card.html' with article=article %}
{% empty %}
<p class="font-mono text-zinc-500 py-12 text-center">No articles found.</p>
{% endfor %}
</div>
<!-- Pagination -->
{% if articles.has_previous or articles.has_next %}
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
{% empty %}
<p>No articles found.</p>
{% endfor %}
<nav aria-label="Pagination">
{% if articles.has_previous %}
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Previous</a>
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Previous</a>
{% endif %}
<span class="text-zinc-500">Page {{ articles.number }} of {{ paginator.num_pages }}</span>
<span>Page {{ articles.number }} of {{ paginator.num_pages }}</span>
{% if articles.has_next %}
<a href="?page={{ articles.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Next</a>
<a href="?page={{ articles.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Next</a>
{% endif %}
</nav>
{% endif %}
{% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% load wagtailcore_tags wagtailimages_tags seo_tags core_tags %}
{% load wagtailcore_tags wagtailimages_tags seo_tags %}
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
{% block head_meta %}
{% canonical_url page as canonical %}
@@ -17,142 +17,75 @@
{% if og_image %}<meta name="twitter:image" content="{{ og_image }}" />{% endif %}
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<div class="mb-8 font-mono text-sm text-zinc-500">
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a> /
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a> /
<span class="text-brand-dark dark:text-brand-light">{{ page.title|truncatechars:40 }}</span>
</div>
<!-- Article Header -->
<header class="mb-12 border-b border-zinc-200 dark:border-zinc-800 pb-12">
<div class="flex gap-3 mb-6 items-center flex-wrap">
{% for tag in page.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }} border {{ tag|get_tag_border_css }}">{{ tag.name }}</span>
{% endfor %}
<span class="text-sm font-mono text-zinc-500"><svg class="w-4 h-4 inline mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg> {{ page.first_published_at|date:"M j, Y" }}</span>
<span class="text-sm font-mono text-zinc-500"><svg class="w-4 h-4 inline mr-1 -mt-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg> {{ page.read_time_mins }} min read</span>
</div>
<h1 class="font-display font-black text-4xl md:text-6xl lg:text-7xl leading-tight mb-8">{{ page.title }}</h1>
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
<div>
<div class="font-bold font-display text-lg">{{ page.author.name }}</div>
{% if page.author.role %}<div class="font-mono text-xs text-zinc-500">{{ page.author.role }}</div>{% endif %}
</div>
</div>
</header>
{% if page.hero_image %}
<div class="mb-12 border border-zinc-200 dark:border-zinc-800 overflow-hidden">
{% image page.hero_image width-1200 class="w-full h-auto" %}
</div>
{% endif %}
<!-- Main Content Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<!-- Article Body -->
<article class="lg:col-span-8 prose prose-lg dark:prose-invert max-w-none
prose-headings:font-display prose-headings:font-bold
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline
prose-img:border prose-img:border-zinc-200 dark:prose-img:border-zinc-800
prose-blockquote:border-l-brand-pink prose-blockquote:bg-brand-pink/5 prose-blockquote:py-2 prose-blockquote:not-italic
prose-code:font-mono prose-code:text-brand-cyan prose-code:bg-zinc-100 dark:prose-code:bg-zinc-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none">
<article>
<h1>{{ page.title }}</h1>
<p>{{ page.read_time_mins }} min read</p>
{% if page.hero_image %}
{% image page.hero_image fill-1200x630 %}
{% endif %}
{{ page.body }}
{% article_json_ld page %}
</article>
<!-- Sidebar -->
<aside class="lg:col-span-4 space-y-8">
<div class="sticky top-28">
<!-- Share -->
<section aria-label="Share this article" class="mb-8">
<h3 class="font-display font-bold text-lg mb-4 uppercase tracking-widest text-zinc-500 text-sm">Share Article</h3>
<div class="flex gap-2">
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-brand-cyan transition-colors hover:text-brand-cyan" aria-label="Share on X">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer"
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-blue-600 transition-colors hover:text-blue-600" aria-label="Share on LinkedIn">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}"
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-brand-pink transition-colors hover:text-brand-pink" aria-label="Copy link">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /></svg>
</button>
</div>
</section>
<!-- Newsletter -->
<div class="bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark p-6 border border-transparent dark:border-zinc-700 shadow-solid-dark dark:shadow-solid-light">
<h3 class="font-display font-bold text-xl mb-2">Subscribe for Updates</h3>
<p class="text-sm opacity-80 mb-4">Get our latest articles and coding benchmarks delivered to your inbox every week.</p>
{% include 'components/newsletter_form.html' with source='article' label='Subscribe' %}
</div>
</div>
</aside>
</div>
<!-- Related Articles -->
{% if related_articles %}
<section class="mt-16 md:mt-24 pt-12 border-t border-zinc-200 dark:border-zinc-800">
<div class="flex items-center justify-between mb-8">
<h3 class="font-display font-bold text-3xl">Related Articles</h3>
<a href="/articles/" class="font-mono text-sm text-brand-cyan hover:underline">View All</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
{% for article in related_articles %}
<article class="group flex flex-col h-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:-translate-y-2 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
<a href="{{ article.url }}" class="h-48 overflow-hidden relative bg-zinc-900 flex items-center justify-center border-b border-zinc-200 dark:border-zinc-800 shrink-0 block">
{% if article.hero_image %}
{% image article.hero_image fill-400x300 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
{% else %}
<svg class="w-16 h-16 text-brand-cyan opacity-40 group-hover:opacity-80 transition-all duration-500 group-hover:scale-110" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
{% endif %}
</a>
<div class="p-6 flex flex-col flex-grow">
<div class="flex gap-2 mb-3 flex-wrap">
{% for tag in article.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
{% endfor %}
</div>
<a href="{{ article.url }}">
<h4 class="font-display font-bold text-xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
</a>
<p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6 line-clamp-2">{{ article.summary }}</p>
<div class="mt-auto pt-4 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-2 text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
Read Article
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>
</div>
</div>
</article>
{% endfor %}
</div>
</article>
<section aria-label="Share this article">
<h2>Share</h2>
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer">Share on X</a>
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a>
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}">Copy link</button>
</section>
{% endif %}
<!-- Comments -->
<section>
<h2>Related</h2>
{% for article in related_articles %}
<a href="{{ article.url }}">{{ article.title }}</a>
{% endfor %}
</section>
<aside>
<h2>Newsletter</h2>
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %}
</aside>
{% if page.comments_enabled %}
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
<div class="h-1 w-24 bg-gradient-to-r from-brand-cyan to-brand-pink mb-6"></div>
<h2 class="font-display font-bold text-3xl mb-8">Comments</h2>
{% if approved_comments %}
{% include "comments/_comment_list.html" %}
{% else %}
<div id="comments-list" class="space-y-8 mb-12"></div>
<div class="mb-12 p-8 bg-grid-pattern text-center">
<p class="font-mono text-sm text-zinc-500">No comments yet. Be the first to comment.</p>
<section>
<h2>Comments</h2>
{% for comment in approved_comments %}
<article id="comment-{{ comment.id }}">
<p><strong>{{ comment.author_name }}</strong></p>
<p>{{ comment.body }}</p>
{% for reply in comment.replies.all %}
<article id="comment-{{ reply.id }}">
<p><strong>{{ reply.author_name }}</strong></p>
<p>{{ reply.body }}</p>
</article>
{% endfor %}
<form method="post" action="{% url 'comment_post' %}">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
<input type="text" name="author_name" required />
<input type="email" name="author_email" required />
<textarea name="body" required></textarea>
<input type="text" name="honeypot" style="display:none" />
<button type="submit">Reply</button>
</form>
</article>
{% empty %}
<p>No comments yet.</p>
{% endfor %}
{% if comment_form and comment_form.errors %}
<div aria-label="Comment form errors">
{{ comment_form.non_field_errors }}
{% for field in comment_form %}
{{ field.errors }}
{% endfor %}
</div>
{% endif %}
{% include "comments/_comment_form.html" %}
<form method="post" action="{% url 'comment_post' %}">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required />
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required />
<textarea name="body" required>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
<input type="text" name="honeypot" style="display:none" />
<button type="submit">Post comment</button>
</form>
</section>
{% endif %}
{% endblock %}

View File

@@ -1,20 +1,4 @@
<div class="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-6 my-8 flex items-start gap-4
{% if value.icon == 'warning' %}border-l-4 border-l-yellow-400{% elif value.icon == 'trophy' %}border-l-4 border-l-brand-pink{% elif value.icon == 'tip' %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-brand-cyan{% endif %}">
<div class="shrink-0 mt-0.5">
{% if value.icon == 'warning' %}
<svg class="w-6 h-6 text-yellow-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
{% elif value.icon == 'trophy' %}
<svg class="w-6 h-6 text-brand-pink" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0" /></svg>
{% elif value.icon == 'tip' %}
<svg class="w-6 h-6 text-green-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg>
{% else %}
<svg class="w-6 h-6 text-brand-cyan" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>
{% endif %}
</div>
<div class="min-w-0">
{% if value.heading %}
<h4 class="font-display font-bold text-lg mb-2">{{ value.heading }}</h4>
{% endif %}
<div class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ value.body }}</div>
</div>
<div class="callout icon-{{ value.icon }}">
<h3>{{ value.heading }}</h3>
{{ value.body }}
</div>

View File

@@ -1,19 +1,5 @@
{% load wagtailcore_tags %}
<div class="my-8 rounded-md overflow-hidden bg-[#0d1117] border border-zinc-800 shadow-xl">
<div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-zinc-800">
<div class="flex gap-2">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
{% if value.filename %}
<div class="font-mono text-xs text-zinc-500">{{ value.filename }}</div>
{% else %}
<div class="font-mono text-xs text-zinc-500">{{ value.language }}</div>
{% endif %}
<div class="w-8"></div>
</div>
<div class="overflow-x-auto">
<pre data-lang="{{ value.language }}" class="p-6 text-sm"><code class="language-{{ value.language }} font-mono text-zinc-300">{{ value.raw_code }}</code></pre>
</div>
<div class="code-block">
{% if value.filename %}<div>{{ value.filename }}</div>{% endif %}
<pre data-lang="{{ value.language }}"><code class="language-{{ value.language }}">{{ value.raw_code }}</code></pre>
</div>

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %}
{% load wagtailimages_tags seo_tags core_tags %}
{% load seo_tags %}
{% block title %}No Hype AI{% endblock %}
{% block head_meta %}
{% canonical_url page as canonical %}
@@ -11,156 +11,21 @@
<meta property="og:url" content="{{ canonical }}" />
{% endblock %}
{% block content %}
<!-- Featured Article -->
{% if featured_article %}
<section class="mb-12 md:mb-16">
<div class="flex items-center gap-2 mb-6">
<span class="w-2 h-2 rounded-full bg-brand-pink animate-pulse"></span>
<span class="font-mono text-sm font-bold uppercase tracking-widest text-zinc-500">Featured Article</span>
</div>
<article class="group cursor-pointer grid grid-cols-1 lg:grid-cols-2 gap-8 items-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 md:p-6 hover:border-brand-cyan dark:hover:border-brand-cyan hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
<a href="{{ featured_article.url }}" class="w-full h-64 md:h-[400px] overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 order-2 lg:order-1 border border-zinc-200 dark:border-zinc-800 block">
{% if featured_article.hero_image %}
{% image featured_article.hero_image fill-800x600 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-700 group-hover:scale-105" %}
{% else %}
<div class="absolute inset-0 flex items-center justify-center">
<svg class="w-20 h-20 text-brand-cyan opacity-30" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
</div>
<section>
{% if featured_article %}
<h2>{{ featured_article.title }}</h2>
<p>{{ featured_article.author.name }}</p>
<p>{{ featured_article.read_time_mins }} min read</p>
{% endif %}
</a>
<div class="flex flex-col py-2 order-1 lg:order-2">
<div class="flex gap-3 mb-4 items-center flex-wrap">
{% for tag in featured_article.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
{% endfor %}
<span class="text-sm font-mono text-zinc-500"><svg class="w-3 h-3 inline mr-1 -mt-0.5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>{{ featured_article.read_time_mins }} min read</span>
</div>
<a href="{{ featured_article.url }}">
<h2 class="font-display font-black text-3xl md:text-5xl mb-4 group-hover:text-brand-cyan transition-colors leading-[1.1]">{{ featured_article.title }}</h2>
</a>
<p class="text-zinc-600 dark:text-zinc-400 mb-8 text-lg md:text-xl line-clamp-3">{{ featured_article.summary }}</p>
<div class="mt-auto flex items-center justify-between pt-4 border-t border-zinc-200 dark:border-zinc-800">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink"></div>
<div>
<div class="text-sm font-bold font-display">{{ featured_article.author.name }}</div>
<div class="text-xs font-mono text-zinc-500">{{ featured_article.first_published_at|date:"M j, Y" }}</div>
</div>
</div>
<a href="{{ featured_article.url }}" class="text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors flex items-center gap-1">
Read
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" /></svg>
</a>
</div>
</div>
</article>
</section>
{% endif %}
<!-- 2-Column Editorial Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
<!-- Main Feed -->
<div class="lg:col-span-8">
<div class="flex items-center justify-between mb-8 pb-4 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="font-display font-bold text-3xl">Latest Articles</h3>
<a href="/articles/" class="font-mono text-sm text-brand-cyan hover:underline flex items-center gap-1">View All</a>
</div>
<div class="space-y-8">
<section>
{% for article in latest_articles %}
<article class="group flex flex-col md:flex-row gap-6 items-start pb-8 border-b border-zinc-200 dark:border-zinc-800 last:border-0">
<a href="{{ article.url }}" class="w-full md:w-48 h-48 md:h-32 shrink-0 overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 flex items-center justify-center block">
{% if article.hero_image %}
{% image article.hero_image fill-200x130 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
{% else %}
<svg class="w-12 h-12 text-brand-cyan opacity-40 group-hover:opacity-80 transition-all duration-500 group-hover:scale-110" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
{% endif %}
</a>
<div class="flex flex-col w-full">
<div class="flex gap-3 mb-2 items-center flex-wrap">
{% for tag in article.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
{% include 'components/article_card.html' with article=article %}
{% endfor %}
<span class="text-sm font-mono text-zinc-500">{{ article.first_published_at|date:"M j" }}</span>
</div>
<a href="{{ article.url }}">
<h4 class="font-display font-bold text-2xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
</a>
<p class="text-zinc-600 dark:text-zinc-400 text-sm line-clamp-2 mb-3">{{ article.summary }}</p>
<a href="{{ article.url }}" class="text-xs font-mono font-bold group-hover:text-brand-cyan transition-colors mt-auto">Read article →</a>
</div>
</article>
{% endfor %}
</div>
{% if more_articles %}
<div class="mt-10">
<div class="flex items-center justify-between mb-6 pb-4 border-b border-zinc-200 dark:border-zinc-800">
<h3 class="font-display font-bold text-2xl">More Articles</h3>
</div>
<div class="space-y-6">
</section>
<section>
{% for article in more_articles %}
{% include 'components/article_card.html' with article=article %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<aside class="lg:col-span-4 space-y-8">
<!-- Newsletter Widget -->
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-brand-cyan to-brand-pink"></div>
<h4 class="font-display font-bold text-xl mb-2">Weekly Newsletter</h4>
<p class="text-zinc-600 dark:text-zinc-400 text-sm mb-4">Get our latest articles and coding benchmarks delivered to your inbox every week.</p>
{% include 'components/newsletter_form.html' with source='sidebar' label='Subscribe' %}
</div>
<!-- Popular Articles -->
{% if latest_articles %}
<div>
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Popular Articles</h4>
<ul class="space-y-4">
{% for article in latest_articles %}
<li class="group">
<a href="{{ article.url }}" class="flex gap-4 items-start">
<span class="font-display font-black text-2xl text-zinc-300 dark:text-zinc-800 group-hover:text-brand-{% cycle 'cyan' 'pink' 'cyan' 'pink' 'cyan' %} transition-colors">0{{ forloop.counter }}</span>
<div>
<h5 class="font-display font-bold text-sm leading-tight group-hover:text-brand-cyan transition-colors">{{ article.title }}</h5>
<div class="text-xs font-mono text-zinc-500 mt-1">{{ article.read_time_mins }} min read</div>
</div>
</a>
</li>
{% endfor %}
</ul>
</div>
{% 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">
{% for tag in available_tags %}
<a href="/articles/?tag={{ tag.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">#{{ tag.name }}</a>
{% endfor %}
</div>
</div>
{% endif %}
</aside>
</div>
</section>
{% endblock %}

View File

@@ -1,62 +0,0 @@
{% load wagtailadmin_tags %}
<section class="nice-padding">
<h2 class="visuallyhidden">Articles overview</h2>
{% if drafts %}
<div class="w-mb-4">
<h3><svg class="icon icon-doc-empty" aria-hidden="true"><use href="#icon-doc-empty"></use></svg> Drafts</h3>
<table class="listing">
<tbody>
{% for page in drafts %}
<tr>
<td class="title">
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
</td>
<td>{{ page.latest_revision_created_at|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if scheduled %}
<div class="w-mb-4">
<h3><svg class="icon icon-time" aria-hidden="true"><use href="#icon-time"></use></svg> Scheduled</h3>
<table class="listing">
<tbody>
{% for page in scheduled %}
<tr>
<td class="title">
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
</td>
<td>{{ page.go_live_at|date:"N j, Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if recent %}
<div class="w-mb-4">
<h3><svg class="icon icon-doc-full" aria-hidden="true"><use href="#icon-doc-full"></use></svg> Recently published</h3>
<table class="listing">
<tbody>
{% for page in recent %}
<tr>
<td class="title">
<a href="{% url 'wagtailadmin_pages:edit' page.pk %}">{{ page.title }}</a>
</td>
<td>{{ page.published_date|timesince }} ago</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if not drafts and not scheduled and not recent %}
<p>No articles yet. <a href="{% url 'articles:choose_parent' %}">Create one</a>.</p>
{% endif %}
</section>

View File

@@ -1,59 +0,0 @@
{% extends 'base.html' %}
{% block title %}{% if query %}Search: {{ query }}{% else %}Search{% endif %} | No Hype AI{% endblock %}
{% block head_meta %}
<meta name="robots" content="noindex" />
{% endblock %}
{% block content %}
<!-- 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">Search</h1>
<form action="{% url 'search' %}" method="get" role="search" class="relative w-full md:w-96">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" value="{{ query }}" placeholder="Search articles..." autofocus
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-11 pr-4 py-3 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</form>
{% if query %}
<p class="mt-4 font-mono text-sm text-zinc-500">
{% if results %}{{ results.paginator.count }} result{{ results.paginator.count|pluralize }} for "{{ query }}"{% else %}No results for "{{ query }}"{% endif %}
</p>
{% endif %}
</div>
{% if results %}
<!-- Results -->
<div class="space-y-8">
{% for article in results %}
{% include 'components/article_card.html' with article=article %}
{% endfor %}
</div>
<!-- Pagination -->
{% if results.has_previous or results.has_next %}
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
{% if results.has_previous %}
<a href="?q={{ query|urlencode }}&page={{ results.previous_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">← Previous</a>
{% endif %}
<span class="text-zinc-500">Page {{ results.number }} of {{ paginator.num_pages }}</span>
{% if results.has_next %}
<a href="?q={{ query|urlencode }}&page={{ results.next_page_number }}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Next →</a>
{% endif %}
</nav>
{% endif %}
{% elif query %}
<!-- No Results -->
<div class="py-16 text-center">
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<p class="font-mono text-zinc-500 mb-2">No articles match your search.</p>
<p class="font-mono text-sm text-zinc-400">Try different keywords or browse <a href="/articles/" class="text-brand-cyan hover:underline">all articles</a>.</p>
</div>
{% else %}
<!-- Empty State -->
<div class="py-16 text-center">
<svg class="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<p class="font-mono text-zinc-500">Enter a search term to find articles.</p>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,42 +0,0 @@
<div class="group">
<!-- Top-level Comment -->
<article id="comment-{{ comment.id }}" class="relative bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 sm:p-8 hover:border-brand-pink/30 transition-colors">
<div class="flex items-start gap-4 mb-4">
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0 rounded-sm shadow-solid-dark/10 dark:shadow-solid-light/5"></div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span class="font-display font-bold text-base text-zinc-900 dark:text-zinc-100">{{ comment.author_name }}</span>
<time datetime="{{ comment.created_at|date:'c' }}" class="font-mono text-xs text-zinc-500 uppercase tracking-wider">{{ comment.created_at|date:"M j, Y" }}</time>
</div>
<div class="mt-3 prose prose-sm dark:prose-invert max-w-none text-zinc-700 dark:text-zinc-300 leading-relaxed">
{{ comment.body|linebreaks }}
</div>
</div>
</div>
<div class="flex items-center justify-between mt-6">
{% include "comments/_reactions.html" with comment=comment counts=comment.reaction_counts user_reacted=comment.user_reacted %}
<details class="group/details">
<summary class="list-none cursor-pointer flex items-center gap-2 font-mono text-xs font-bold uppercase tracking-widest text-zinc-500 hover:text-brand-pink transition-colors [&::-webkit-details-marker]:hidden">
<svg class="w-4 h-4 transition-transform group-open/details:-translate-y-0.5 group-open/details:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
<span class="group-open/details:hidden">Reply</span>
<span class="hidden group-open/details:inline text-brand-pink">Cancel Reply</span>
</summary>
<div class="mt-8 pt-8 border-t border-zinc-100 dark:border-zinc-800 animate-in fade-in slide-in-from-top-2 duration-300">
{% include "comments/_reply_form.html" with page=page comment=comment %}
</div>
</details>
</div>
</article>
<!-- Nested Replies -->
<div class="replies-container relative ml-6 sm:ml-12 mt-4 space-y-4 pl-6 sm:pl-8 border-l-2 border-zinc-100 dark:border-zinc-800">
{% for reply in comment.replies.all %}
{% include "comments/_reply.html" with reply=reply %}
{% endfor %}
</div>
</div>

View File

@@ -1,66 +0,0 @@
{% load static %}
<div id="comment-form-container" class="relative bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 p-8 sm:p-12 shadow-solid-pink">
<div class="max-w-2xl">
<h3 class="font-display font-bold text-3xl mb-2">Join the conversation</h3>
<p class="font-mono text-sm text-zinc-400 dark:text-zinc-500 mb-10 uppercase tracking-widest">Add your fresh comment below</p>
{% if success_message %}
<div class="mb-8 p-4 bg-brand-cyan/10 border border-brand-cyan/20 font-mono text-sm text-brand-cyan animate-in fade-in">
{{ success_message }}
</div>
{% endif %}
{% if comment_form.errors %}
<div aria-label="Comment form errors" class="mb-8 p-4 bg-red-500/10 border border-red-500/20 font-mono text-sm text-red-400">
<div class="font-bold mb-2 uppercase tracking-widest text-xs">There were some errors:</div>
<ul class="list-disc list-inside">
{% if comment_form.non_field_errors %}
{% for error in comment_form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
{% endif %}
{% for field in comment_form %}
{% if field.errors %}
{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}
{% endif %}
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-6"
hx-post="{% url 'comment_post' %}" hx-target="#comment-form-container" hx-swap="outerHTML">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Full Name</label>
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
</div>
<div class="space-y-2">
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Email Address</label>
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
</div>
</div>
<div class="space-y-2">
<label class="block font-mono text-[10px] uppercase tracking-[0.2em] opacity-60">Your Thoughts</label>
<textarea name="body" required rows="5"
class="w-full bg-white/5 dark:bg-black/5 border-b-2 border-white/20 dark:border-black/20 px-0 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
</div>
<input type="text" name="honeypot" hidden />
{% if turnstile_site_key %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="auto"></div>
{% endif %}
<div class="pt-4">
<button type="submit" class="group relative inline-flex items-center gap-3 px-8 py-4 bg-brand-pink text-white font-display font-bold uppercase tracking-widest text-sm hover:-translate-y-1 transition-all active:translate-y-0">
<span>Post comment</span>
<svg class="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</button>
</div>
</form>
</div>
</div>

View File

@@ -1,6 +0,0 @@
<div id="comments-list" class="space-y-12 mb-16"
hx-get="{% url 'comment_poll' article_id=page.id %}" hx-trigger="every 30s" hx-swap="innerHTML">
{% for comment in approved_comments %}
{% include "comments/_comment.html" with comment=comment page=page %}
{% endfor %}
</div>

View File

@@ -1,3 +0,0 @@
{% for comment in approved_comments %}
{% include "comments/_comment.html" with comment=comment page=page %}
{% endfor %}

View File

@@ -1,3 +0,0 @@
<div id="comment-notice" class="mb-4 p-3 font-mono text-sm bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20">
{{ message|default:"Your comment has been posted and is awaiting moderation." }}
</div>

View File

@@ -1,12 +0,0 @@
<div class="flex gap-3 mt-3 items-center" id="reactions-{{ comment.id }}">
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
hx-vals='{"reaction_type": "heart"}' class="flex items-center gap-1 font-mono text-xs {% if 'heart' in user_reacted %}text-brand-pink{% else %}text-zinc-400 hover:text-brand-pink{% endif %} transition-colors hover:scale-110 transition-transform">
<svg class="w-4 h-4" fill="{% if 'heart' in user_reacted %}currentColor{% else %}none{% endif %}" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /></svg>
<span>{{ counts.heart|default:"0" }}</span>
</button>
<button hx-post="{% url 'comment_react' comment.id %}" hx-target="#reactions-{{ comment.id }}" hx-swap="outerHTML"
hx-vals='{"reaction_type": "plus_one"}' class="flex items-center gap-1 font-mono text-xs {% if 'plus_one' in user_reacted %}text-brand-cyan{% else %}text-zinc-400 hover:text-brand-cyan{% endif %} transition-colors hover:scale-110 transition-transform">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
<span>{{ counts.plus_one|default:"0" }}</span>
</button>
</div>

View File

@@ -1,14 +0,0 @@
<article id="comment-{{ reply.id }}" class="bg-zinc-50/50 dark:bg-zinc-900/30 border border-zinc-100 dark:border-zinc-800 p-5 sm:p-6">
<div class="flex items-start gap-3 mb-3">
<div class="w-8 h-8 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0 rounded-sm"></div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-baseline gap-x-2">
<span class="font-display font-bold text-sm text-zinc-900 dark:text-zinc-100">{{ reply.author_name }}</span>
<time datetime="{{ reply.created_at|date:'c' }}" class="font-mono text-[10px] text-zinc-400 uppercase tracking-wider">{{ reply.created_at|date:"M j, Y" }}</time>
</div>
<div class="mt-2 prose prose-sm dark:prose-invert max-w-none text-zinc-600 dark:text-zinc-400 leading-relaxed text-sm">
{{ reply.body|linebreaks }}
</div>
</div>
</div>
</article>

View File

@@ -1,46 +0,0 @@
{% load static %}
<div id="reply-form-container-{{ comment.id }}">
<h4 class="font-display font-bold text-sm mb-4 uppercase tracking-wider">Reply to {{ comment.author_name }}</h4>
{% if reply_success_message %}
<div class="mb-6 p-4 bg-brand-cyan/10 border border-brand-cyan/20 font-mono text-sm text-brand-cyan animate-in fade-in">
{{ reply_success_message }}
</div>
{% endif %}
{% if reply_form_errors %}
<div aria-label="Comment form errors" class="mb-6 p-4 bg-red-500/10 border border-red-500/20 font-mono text-sm text-red-400 animate-in shake-1">
<div class="font-bold mb-2 uppercase tracking-widest text-xs">Errors:</div>
<ul class="list-disc list-inside">
{% for field, errors in reply_form_errors.items %}
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
{% endfor %}
</ul>
</div>
{% endif %}
<form method="post" action="{% url 'comment_post' %}"
hx-post="{% url 'comment_post' %}" hx-target="#reply-form-container-{{ comment.id }}" hx-swap="outerHTML"
class="space-y-4">
{% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<input type="text" name="author_name" required placeholder="Name *"
class="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all" />
<input type="email" name="author_email" required placeholder="Email *"
class="bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all" />
</div>
<textarea name="body" required placeholder="Your reply..." rows="3"
class="w-full bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink focus:ring-1 focus:ring-brand-pink transition-all resize-none"></textarea>
<input type="text" name="honeypot" hidden />
{% if turnstile_site_key %}
<div class="cf-turnstile mb-4" data-sitekey="{{ turnstile_site_key }}" data-theme="auto" data-size="flexible"></div>
{% endif %}
<div class="flex justify-end gap-3">
<button type="submit" data-testid="post-reply-btn" class="px-6 py-2 bg-brand-pink text-white font-display font-bold text-sm shadow-solid-dark hover:-translate-y-0.5 hover:shadow-solid-dark/80 transition-all active:translate-y-0">Post Reply</button>
</div>
</form>
</div>

View File

@@ -1,53 +0,0 @@
{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
{% load i18n wagtailusers_tags wagtailadmin_tags %}
{% block titletag %}
{% if items|length == 1 %}
{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove {{ snippet_type_name }}{% endblocktrans %} - {{ items.0.item }}
{% else %}
{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} comments{% endblocktrans %}
{% endif %}
{% endblock %}
{% block header %}
{% trans "Unapprove" as unapprove_str %}
{% if items|length == 1 %}
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=items.0.item icon=header_icon only %}
{% else %}
{% include "wagtailadmin/shared/header.html" with title=unapprove_str subtitle=model_opts.verbose_name_plural|capfirst icon=header_icon only %}
{% endif %}
{% endblock header %}
{% block items_with_access %}
{% if items %}
{% if items|length == 1 %}
<p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Unapprove this {{ snippet_type_name }}?{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed with count=items|length|intcomma %}Unapprove {{ count }} selected comments?{% endblocktrans %}</p>
<ul>
{% for snippet in items %}
<li><a href="{{ snippet.edit_url }}" target="_blank" rel="noreferrer">{{ snippet.item }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
{% endblock items_with_access %}
{% block items_with_no_access %}
{% if items_with_no_access|length == 1 %}
{% trans "You don't have permission to unapprove this comment" as no_access_msg %}
{% else %}
{% trans "You don't have permission to unapprove these comments" as no_access_msg %}
{% endif %}
{% include 'wagtailsnippets/bulk_actions/list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
{% endblock items_with_no_access %}
{% block form_section %}
{% if items %}
{% trans "Yes, unapprove" as action_button_text %}
{% trans "No, go back" as no_action_button_text %}
{% include 'wagtailadmin/bulk_actions/confirmation/form.html' %}
{% else %}
{% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
{% endif %}
{% endblock form_section %}

View File

@@ -1,28 +1,8 @@
{% load core_tags wagtailimages_tags %}
<article class="group flex flex-col md:flex-row gap-8 items-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 hover:border-brand-cyan dark:hover:border-brand-cyan transition-colors">
<a href="{{ article.url }}" class="w-full md:w-1/3 h-48 md:h-full min-h-[200px] overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 shrink-0 block">
{% if article.hero_image %}
{% image article.hero_image fill-600x400 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
{% else %}
<div class="w-full h-full min-h-[200px] flex items-center justify-center bg-zinc-900">
<svg class="w-16 h-16 text-brand-cyan opacity-40 group-hover:opacity-80 transition-opacity" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
</div>
{% endif %}
</a>
<div class="flex flex-col py-2 w-full">
<div class="flex gap-3 mb-4 items-center flex-wrap">
{% load core_tags %}
<article>
<a href="{{ article.url }}">{{ article.title }}</a>
<p>{{ article.summary|truncatewords:20 }}</p>
{% for tag in article.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
<span class="{{ tag|get_tag_css }}">{{ tag.name }}</span>
{% endfor %}
<span class="text-sm font-mono text-zinc-500">{{ article.first_published_at|date:"M j, Y" }}</span>
</div>
<a href="{{ article.url }}">
<h2 class="font-display font-bold text-2xl md:text-3xl mb-3 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h2>
</a>
<p class="text-zinc-600 dark:text-zinc-400 mb-6 max-w-2xl line-clamp-2">{{ article.summary }}</p>
<a href="{{ article.url }}" class="flex items-center gap-2 mt-auto text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
Read Article
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>
</a>
</div>
</article>

View File

@@ -1,43 +1,27 @@
{% if request.consent.requires_prompt %}
<div id="cookie-banner" class="fixed bottom-0 left-0 right-0 z-50 bg-brand-surfaceLight dark:bg-brand-surfaceDark border-t border-zinc-200 dark:border-zinc-800 shadow-lg">
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div class="flex-1">
<p class="font-mono text-sm text-zinc-600 dark:text-zinc-400">
We use cookies to improve your experience.
{% if site_settings and site_settings.privacy_policy_page %}
<a href="{{ site_settings.privacy_policy_page.url }}" class="text-brand-cyan hover:underline">Privacy Policy</a>
{% endif %}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<form method="post" action="{% url 'consent' %}" class="inline">
<div id="cookie-banner">
<form method="post" action="{% url 'consent' %}">
{% csrf_token %}
<button type="submit" name="reject_all" value="1"
class="px-4 py-2 border border-zinc-300 dark:border-zinc-700 font-mono text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Reject all</button>
<button type="submit" name="accept_all" value="1">Accept all</button>
<button type="submit" name="reject_all" value="1">Reject all</button>
</form>
<form method="post" action="{% url 'consent' %}" class="inline">
<details>
<summary>Manage preferences</summary>
<form method="post" action="{% url 'consent' %}">
{% csrf_token %}
<button type="submit" name="accept_all" value="1"
class="px-4 py-2 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold text-sm hover:bg-brand-cyan transition-colors">Accept all</button>
</form>
<details class="relative">
<summary class="px-4 py-2 border border-zinc-300 dark:border-zinc-700 font-mono text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer list-none">Manage</summary>
<div class="absolute bottom-full right-0 mb-2 w-72 bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 shadow-lg">
<form method="post" action="{% url 'consent' %}" class="space-y-3">
{% csrf_token %}
<label class="flex items-center gap-3 font-mono text-sm cursor-pointer">
<input type="checkbox" name="analytics" value="1" class="accent-brand-cyan" />
<label>
<input type="checkbox" name="analytics" value="1" />
Analytics cookies
</label>
<label class="flex items-center gap-3 font-mono text-sm cursor-pointer">
<input type="checkbox" name="advertising" value="1" class="accent-brand-pink" />
<label>
<input type="checkbox" name="advertising" value="1" />
Advertising cookies
</label>
<button type="submit" class="w-full px-4 py-2 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold text-sm hover:bg-brand-cyan transition-colors">Save preferences</button>
<button type="submit">Save preferences</button>
</form>
</div>
</details>
</div>
</div>
{% if site_settings and site_settings.privacy_policy_page %}
<a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a>
{% endif %}
</div>
{% endif %}

View File

@@ -1,42 +1,8 @@
{% load core_tags %}
{% get_nav_items "footer" as footer_nav_items %}
{% get_social_links as social_links %}
<footer class="border-t border-zinc-200 dark:border-zinc-800 bg-brand-light dark:bg-brand-dark mt-12 py-12 text-center md:text-left">
<div class="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
<div class="md:col-span-2">
<a href="/" class="font-display font-bold text-2xl tracking-tight mb-4 inline-block">{{ site_settings.site_name|default:"NO HYPE AI" }}</a>
<p class="text-zinc-500 font-mono text-sm max-w-sm mx-auto md:mx-0">
{{ site_settings.footer_description|default:"In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers."|linebreaksbr }}
</p>
</div>
<div>
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Navigation</h4>
<ul class="space-y-2 font-mono text-sm text-zinc-500">
{% for item in footer_nav_items %}
<li><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></li>
{% endfor %}
<footer>
{% get_legal_pages as legal_pages %}
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
{% for page in legal_pages %}
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
<a href="{{ page.url }}">{{ page.title }}</a>
{% endfor %}
</ul>
</div>
<div>
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Connect</h4>
<ul class="space-y-2 font-mono text-sm text-zinc-500">
{% for link in social_links %}
<li>
<a href="{{ link.url }}" class="hover:text-brand-cyan transition-colors flex items-center justify-center md:justify-start gap-2"{% if link.url != "/feed/" %} target="_blank" rel="noopener noreferrer"{% endif %}>
{% include link.icon_template %}
{{ link.display_label }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800 text-center font-mono text-xs text-zinc-500 flex flex-col md:flex-row justify-between items-center gap-4">
<p>&copy; {% now "Y" %} {{ site_settings.copyright_text|default:"No Hype AI. All rights reserved." }}</p>
<p>{{ site_settings.tagline|default:"Honest AI tool reviews for developers." }}</p>
</div>
</footer>

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Z" /></svg>

Before

Width:  |  Height:  |  Size: 334 B

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>

Before

Width:  |  Height:  |  Size: 266 B

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>

Before

Width:  |  Height:  |  Size: 783 B

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>

Before

Width:  |  Height:  |  Size: 671 B

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>

Before

Width:  |  Height:  |  Size: 330 B

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>

Before

Width:  |  Height:  |  Size: 919 B

View File

@@ -1 +0,0 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-8.9a.75.75 0 0 0-.53-1.1H13.5l-1.5 3-1.5-3H4.06a.75.75 0 0 0-.53 1.1l4.72 8.9-4.72 8.9a.75.75 0 0 0 .53 1.1H10.5l1.5-3 1.5 3h6.44a.75.75 0 0 0 .53-1.1l-4.72-8.9Z" /></svg>

Before

Width:  |  Height:  |  Size: 374 B

View File

@@ -1,82 +1,7 @@
{% 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 -->
<a href="/" class="group flex items-center gap-2">
<div class="w-8 h-8 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark flex items-center justify-center font-display font-bold text-xl group-hover:rotate-12 transition-transform">
/
</div>
<span class="font-display font-bold text-2xl tracking-tight">{{ site_settings.site_name|default:"NO HYPE AI" }}</span>
</a>
<!-- Desktop Links -->
<div class="hidden md:flex items-center gap-8 font-medium">
{% 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 %}
<form action="{% url 'search' %}" method="get" role="search" class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
class="w-48 bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</form>
</div>
<!-- Theme Toggle + Hamburger -->
<div class="flex items-center gap-4">
<button type="button" data-theme-toggle class="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors" aria-label="Toggle theme">
<svg class="w-5 h-5 hidden dark:block text-yellow-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /></svg>
<svg class="w-5 h-5 block dark:hidden text-zinc-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /></svg>
</button>
<button type="button" data-mobile-menu-toggle class="md:hidden p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded transition-colors" aria-label="Open menu" aria-expanded="false" aria-controls="mobile-menu">
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
</button>
</div>
</div>
<nav>
<a href="/">Home</a>
<a href="/articles/">Articles</a>
<a href="/about/">About</a>
<button type="button" data-theme-toggle>Toggle theme</button>
{% include 'components/newsletter_form.html' with source='nav' label='Get updates' %}
</nav>
<!-- Mobile Menu (outside <nav> to avoid duplicate form[data-newsletter-form] in nav scope) -->
<div id="mobile-menu" class="md:hidden hidden sticky top-20 z-40 border-b border-zinc-200 dark:border-zinc-800 bg-brand-light/95 dark:bg-brand-dark/95 backdrop-blur-md">
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col gap-4">
{% 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 action="{% url 'search' %}" method="get" role="search" class="pt-2 border-t border-zinc-200 dark:border-zinc-800">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
<input type="search" name="q" placeholder="Search articles..." aria-label="Search articles"
class="w-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-300 dark:border-zinc-700 pl-9 pr-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-cyan dark:focus:border-brand-cyan focus:ring-1 focus:ring-brand-cyan transition-shadow" />
</div>
</form>
<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" />
<input type="email" name="email" required placeholder="dev@example.com"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
<input type="text" name="honeypot" hidden />
<button type="submit" class="w-full px-4 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold transition-colors">Subscribe</button>
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
</form>
</div>
</div>
<script nonce="{{ request.csp_nonce|default:'' }}">
(function(){
var btn = document.querySelector('[data-mobile-menu-toggle]');
var menu = document.getElementById('mobile-menu');
if (btn && menu) {
btn.addEventListener('click', function(){
var isOpen = !menu.classList.contains('hidden');
menu.classList.toggle('hidden');
btn.setAttribute('aria-expanded', String(!isOpen));
});
}
})();
</script>

View File

@@ -1,12 +1,11 @@
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-3" id="newsletter">
<form method="post" action="/newsletter/subscribe/" data-newsletter-form>
{% csrf_token %}
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
<input type="email" name="email" required placeholder="dev@example.com"
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
<input type="text" name="honeypot" hidden />
<button type="submit"
class="w-full bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold py-2 hover:bg-brand-pink dark:hover:bg-brand-pink hover:text-white transition-colors">
{{ label|default:"Subscribe" }}
</button>
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
<label>
<span>{{ label|default:"Newsletter" }}</span>
<input type="email" name="email" required />
</label>
<input type="text" name="honeypot" style="display:none" />
<button type="submit">Subscribe</button>
<p data-newsletter-message aria-live="polite"></p>
</form>

View File

@@ -1,18 +1,7 @@
{% extends 'base.html' %}
{% load wagtailcore_tags %}
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
{% block content %}
<!-- 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-5xl mb-3">{{ page.title }}</h1>
<p class="font-mono text-sm text-zinc-500">Last updated: {{ page.last_updated|date:'F Y' }}</p>
</div>
<!-- Body -->
<div class="max-w-3xl prose prose-lg dark:prose-invert
prose-headings:font-display prose-headings:font-bold
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline">
{{ page.body|richtext }}
</div>
<h1>{{ page.title }}</h1>
<p>Last updated: {{ page.last_updated|date:'F Y' }}</p>
{{ page.body|richtext }}
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,6 @@
"name": "nohype-theme",
"version": "1.0.0",
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"tailwindcss": "^3.4.17"
}
},
@@ -94,33 +93,6 @@
"node": ">= 8"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",

View File

@@ -7,7 +7,6 @@
"dev": "tailwindcss -i ./src/input.css -o ../static/css/styles.css --watch"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"tailwindcss": "^3.4.17"
}
}

View File

@@ -1,40 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.bg-grid-pattern {
background-image: radial-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px);
background-size: 24px 24px;
}
.dark .bg-grid-pattern {
background-image: radial-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px);
}
.text-gradient {
background: linear-gradient(135deg, #06b6d4, #ec4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
::selection {
background-color: #ec4899;
color: #ffffff;
}
pre {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
pre::-webkit-scrollbar {
height: 6px;
}
pre::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
body {
transition: background-color 0.3s ease, color 0.3s ease;
}

View File

@@ -1,36 +1,10 @@
module.exports = {
darkMode: 'class',
content: [
"../../templates/**/*.html",
"../../apps/**/templates/**/*.html",
"../../apps/blog/models.py",
"../../apps/**/templates/**/*.html"
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
display: ['Space Grotesk', 'sans-serif'],
mono: ['Fira Code', 'monospace'],
extend: {}
},
colors: {
brand: {
cyan: '#06b6d4',
cyanGlow: 'rgba(6,182,212,0.4)',
pink: '#ec4899',
dark: '#09090b',
light: '#fafafa',
surfaceDark: '#18181b',
surfaceLight: '#ffffff',
},
},
boxShadow: {
'neon-cyan': '0 0 20px rgba(6, 182, 212, 0.3)',
'neon-pink': '0 0 20px rgba(236, 72, 153, 0.3)',
'solid-dark': '6px 6px 0px 0px #09090b',
'solid-light': '6px 6px 0px 0px #e4e4e7',
'solid-pink': '6px 6px 0px 0px #ec4899',
},
},
},
plugins: [require('@tailwindcss/typography')],
plugins: []
};