Compare commits
24 Commits
fix/csp-go
...
98175e2fc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98175e2fc5 | ||
| 22d596d666 | |||
|
|
987f308e06 | ||
| bcc9305a00 | |||
|
|
62ff7f5792 | ||
| ad271aa817 | |||
|
|
8a97b6e2a0 | ||
| 43e7068110 | |||
|
|
6bae864c1e | ||
| 17d30a4073 | |||
|
|
0818f71566 | ||
| 3799d76bed | |||
|
|
fbe8546b37 | ||
| a59d21cfcb | |||
|
|
43594777e0 | ||
| f7c89be05c | |||
|
|
2e7949ac23 | ||
| f5c2f87820 | |||
|
|
abbc3c3d1d
|
||
| c028a83bef | |||
|
|
155c8f7569
|
||
| d83f7db57c | |||
|
|
221c8c19c2
|
||
| c0cd4e5037 |
@@ -18,6 +18,7 @@ RUN set -eux; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
libavif-dev \
|
||||
curl \
|
||||
nodejs \
|
||||
npm \
|
||||
|
||||
123
Makefile
Normal file
@@ -0,0 +1,123 @@
|
||||
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),)
|
||||
@@ -42,6 +42,11 @@ class HomePage(Page):
|
||||
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")
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -95,12 +100,12 @@ class TagMetadata(models.Model):
|
||||
|
||||
@classmethod
|
||||
def get_fallback_css(cls) -> dict[str, str]:
|
||||
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
|
||||
return {"bg": "bg-zinc-800 dark:bg-zinc-100", "text": "text-white dark:text-black"}
|
||||
|
||||
def get_css_classes(self) -> dict[str, str]:
|
||||
mapping = {
|
||||
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
|
||||
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
|
||||
"cyan": {"bg": "bg-brand-cyan/10", "text": "text-brand-cyan"},
|
||||
"pink": {"bg": "bg-brand-pink/10", "text": "text-brand-pink"},
|
||||
"neutral": self.get_fallback_css(),
|
||||
}
|
||||
return mapping.get(self.colour, self.get_fallback_css())
|
||||
|
||||
@@ -37,6 +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"].startswith("bg-cyan")
|
||||
assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
|
||||
with pytest.raises(IntegrityError):
|
||||
TagMetadata.objects.create(tag=tag, colour="pink")
|
||||
|
||||
@@ -69,8 +69,12 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page):
|
||||
resp = client.get("/")
|
||||
html = resp.content.decode()
|
||||
assert resp.status_code == 200
|
||||
assert 'name="source" value="nav"' in html
|
||||
assert 'name="source" value="footer"' in html
|
||||
# Nav has a Subscribe CTA link (no inline form — wireframe spec)
|
||||
assert 'href="#newsletter"' 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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from django.core.cache import cache
|
||||
from django.test import override_settings
|
||||
|
||||
from apps.comments.forms import CommentForm
|
||||
|
||||
@@ -11,6 +12,7 @@ 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 = {
|
||||
|
||||
@@ -33,7 +33,8 @@ class CommentCreateView(View):
|
||||
ip = client_ip_from_request(request)
|
||||
key = f"comment-rate:{ip}"
|
||||
count = cache.get(key, 0)
|
||||
if count >= 3:
|
||||
rate_limit = getattr(settings, "COMMENT_RATE_LIMIT_PER_MINUTE", 3)
|
||||
if count >= rate_limit:
|
||||
return HttpResponse(status=429)
|
||||
cache.set(key, count + 1, timeout=60)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ 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
|
||||
|
||||
|
||||
@@ -51,7 +53,17 @@ class Command(BaseCommand):
|
||||
article_index.add_child(instance=article)
|
||||
article.save_revision().publish()
|
||||
|
||||
# Tagged article — used by tag-filter E2E tests
|
||||
# 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,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -128,4 +140,40 @@ class Command(BaseCommand):
|
||||
site.is_default_site = True
|
||||
site.save()
|
||||
|
||||
# Navigation menu items and social links
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
if not NavigationMenuItem.objects.filter(settings=settings).exists():
|
||||
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)
|
||||
|
||||
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,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||
|
||||
@@ -18,10 +18,14 @@ 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}'; "
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
112
apps/core/migrations/0003_seed_navigation_data.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# 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,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def reverse_seed(apps, schema_editor):
|
||||
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
|
||||
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
|
||||
NavigationMenuItem.objects.all().delete()
|
||||
SocialMediaLink.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_sitesettings_copyright_text_and_more'),
|
||||
('wagtailcore', '0094_alter_page_locale'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_navigation_data, reverse_seed),
|
||||
]
|
||||
@@ -1,10 +1,24 @@
|
||||
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(BaseSiteSetting):
|
||||
class SiteSettings(ClusterableModel, BaseSiteSetting):
|
||||
default_og_image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
@@ -19,3 +33,140 @@ class SiteSettings(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.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="External URL (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.URLField()
|
||||
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
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import TagMetadata
|
||||
from apps.core.models import SiteSettings
|
||||
from apps.legal.models import LegalPage
|
||||
|
||||
register = template.Library()
|
||||
@@ -20,6 +21,31 @@ 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
|
||||
@register.filter
|
||||
def get_tag_css(tag):
|
||||
|
||||
191
apps/core/tests/test_navigation.py
Normal file
@@ -0,0 +1,191 @@
|
||||
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
|
||||
@@ -26,3 +26,5 @@ try:
|
||||
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
COMMENT_RATE_LIMIT_PER_MINUTE = 100
|
||||
|
||||
@@ -12,39 +12,87 @@ def _go_to_article(page: Page, base_url: str) -> None:
|
||||
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
|
||||
|
||||
|
||||
@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.")
|
||||
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)
|
||||
form.get_by_role("button", name="Post comment").click()
|
||||
|
||||
# Successful submission redirects back to the article with ?commented=1
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None:
|
||||
"""Successful comment submission must show the awaiting-moderation banner."""
|
||||
_go_to_article(page, base_url)
|
||||
_submit_comment(page, body="This is a test comment from Playwright.")
|
||||
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
assert "commented=1" in page.url
|
||||
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
expect(page.get_by_text(unique_body)).not_to_be_visible()
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
||||
form.locator('input[name="author_name"]').fill("E2E Tester")
|
||||
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
|
||||
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.locator('input[name="author_email"]').fill("e2e@example.com")
|
||||
form.locator('textarea[name="body"]').fill(" ") # whitespace-only body
|
||||
form.locator('textarea[name="body"]').fill("Comment without a name.")
|
||||
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
|
||||
expect(page.get_by_text("E2E Approved Commenter")).to_be_visible()
|
||||
# And a Reply button for it
|
||||
expect(page.get_by_role("button", name="Reply")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_reply_submission_redirects(page: Page, base_url: str) -> None:
|
||||
"""Submitting a reply to an approved comment should redirect with commented=1."""
|
||||
_go_to_article(page, base_url)
|
||||
|
||||
# The reply form is always visible below the approved seeded comment
|
||||
reply_form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Reply")).first
|
||||
reply_form.locator('input[name="author_name"]').fill("E2E Replier")
|
||||
reply_form.locator('input[name="author_email"]').fill("replier@example.com")
|
||||
reply_form.locator('textarea[name="body"]').fill("This is a test reply.")
|
||||
reply_form.get_by_role("button", name="Reply").click()
|
||||
|
||||
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
|
||||
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -52,8 +100,7 @@ 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)
|
||||
|
||||
|
||||
@@ -37,12 +37,11 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_newsletter_form_in_nav(page: Page, base_url: str) -> None:
|
||||
def test_nav_subscribe_cta_present(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[type="email"]')).to_be_visible()
|
||||
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible()
|
||||
# Nav has a Subscribe CTA link (not a form — wireframe spec)
|
||||
expect(nav.get_by_role("link", name="Subscribe")).to_be_visible()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
|
||||
@@ -7,7 +7,8 @@ from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def _nav_newsletter_form(page: Page):
|
||||
return page.locator("nav").locator("form[data-newsletter-form]")
|
||||
"""Return the newsletter form in the home page sidebar aside."""
|
||||
return page.locator("aside").locator("form[data-newsletter-form]").first
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
@@ -28,7 +29,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('nav form[data-newsletter-form]').setAttribute('novalidate', '')")
|
||||
page.evaluate("document.querySelector('aside form[data-newsletter-form]').setAttribute('novalidate', '')")
|
||||
form.locator('input[type="email"]').fill("not-an-email")
|
||||
form.get_by_role("button", name="Subscribe").click()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ Django~=5.2.0
|
||||
wagtail~=7.0.0
|
||||
wagtail-seo~=3.1.1
|
||||
psycopg2-binary~=2.9.0
|
||||
Pillow~=11.0.0
|
||||
Pillow~=12.1
|
||||
django-taggit~=6.0.0
|
||||
whitenoise~=6.0.0
|
||||
gunicorn~=23.0.0
|
||||
|
||||
4
static/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 257 B |
@@ -6,6 +6,7 @@
|
||||
<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">
|
||||
|
||||
@@ -178,8 +178,7 @@
|
||||
</div>
|
||||
<textarea name="body" required placeholder="Write a reply..." rows="2"
|
||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors mb-3 resize-none"></textarea>
|
||||
<input type="text" name="honeypot" style="display:none" />
|
||||
<button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
|
||||
<input type="text" name="honeypot" hidden /> <button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
|
||||
</form>
|
||||
</article>
|
||||
{% endfor %}
|
||||
@@ -197,7 +196,7 @@
|
||||
|
||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
||||
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
|
||||
<form method="post" action="{% url 'comment_post' %}" class="space-y-4">
|
||||
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -217,7 +216,7 @@
|
||||
<textarea name="body" required rows="5"
|
||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 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" style="display:none" />
|
||||
<input type="text" name="honeypot" hidden />
|
||||
<button type="submit" class="px-6 py-3 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all">Post comment</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{% 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">NO HYPE AI</a>
|
||||
<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">
|
||||
In-depth reviews and benchmarks of the latest AI coding tools.<br>
|
||||
Honest analysis for developers.
|
||||
{{ 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">
|
||||
<li><a href="/" class="hover:text-brand-cyan transition-colors">Home</a></li>
|
||||
<li><a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a></li>
|
||||
<li><a href="/about/" class="hover:text-brand-pink transition-colors">About</a></li>
|
||||
{% 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 %}
|
||||
{% get_legal_pages as legal_pages %}
|
||||
{% for page in legal_pages %}
|
||||
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
|
||||
@@ -21,13 +22,21 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Newsletter</h4>
|
||||
<p class="text-zinc-500 font-mono text-sm mb-4">Get weekly AI tool reviews.</p>
|
||||
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
|
||||
<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>© {% now "Y" %} No Hype AI. All rights reserved.</p>
|
||||
<p>Honest AI tool reviews for developers.</p>
|
||||
<p>© {% 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>
|
||||
|
||||
1
templates/components/icons/bluesky.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 334 B |
1
templates/components/icons/github.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 266 B |
1
templates/components/icons/linkedin.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 783 B |
1
templates/components/icons/mastodon.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 671 B |
1
templates/components/icons/rss.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 330 B |
1
templates/components/icons/twitter.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 919 B |
1
templates/components/icons/youtube.html
Normal file
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -1,4 +1,5 @@
|
||||
{% load static %}
|
||||
{% load static core_tags %}
|
||||
{% get_nav_items "header" as header_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 -->
|
||||
@@ -6,23 +7,15 @@
|
||||
<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">NO HYPE AI</span>
|
||||
<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">
|
||||
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a>
|
||||
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a>
|
||||
<a href="/about/" class="hover:text-brand-pink transition-colors">About</a>
|
||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="flex items-center gap-2" id="nav-newsletter">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="source" value="nav" />
|
||||
<input type="email" name="email" required placeholder="dev@example.com"
|
||||
class="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 w-44" />
|
||||
<input type="text" name="honeypot" style="display:none" />
|
||||
<button type="submit" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700 whitespace-nowrap">Subscribe</button>
|
||||
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
||||
</form>
|
||||
{% 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 %}
|
||||
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle + Hamburger -->
|
||||
@@ -41,15 +34,15 @@
|
||||
<!-- 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">
|
||||
<a href="/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Home</a>
|
||||
<a href="/articles/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Articles</a>
|
||||
<a href="/about/" class="font-medium py-2 hover:text-brand-pink transition-colors">About</a>
|
||||
{% 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 %}
|
||||
<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" style="display:none" />
|
||||
<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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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" style="display:none" />
|
||||
<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" }}
|
||||
|
||||
@@ -3,6 +3,7 @@ module.exports = {
|
||||
content: [
|
||||
"../../templates/**/*.html",
|
||||
"../../apps/**/templates/**/*.html",
|
||||
"../../apps/blog/models.py",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
|
||||