Compare commits
24 Commits
fix/csp-go
...
1c5ba6cf90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5ba6cf90 | ||
| 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 \
|
apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
libavif-dev \
|
||||||
curl \
|
curl \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
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["featured_article"] = self.featured_article
|
||||||
ctx["latest_articles"] = articles
|
ctx["latest_articles"] = articles
|
||||||
ctx["more_articles"] = articles[:3]
|
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
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -95,12 +100,12 @@ class TagMetadata(models.Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_fallback_css(cls) -> dict[str, str]:
|
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]:
|
def get_css_classes(self) -> dict[str, str]:
|
||||||
mapping = {
|
mapping = {
|
||||||
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
|
"cyan": {"bg": "bg-brand-cyan/10", "text": "text-brand-cyan"},
|
||||||
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
|
"pink": {"bg": "bg-brand-pink/10", "text": "text-brand-pink"},
|
||||||
"neutral": self.get_fallback_css(),
|
"neutral": self.get_fallback_css(),
|
||||||
}
|
}
|
||||||
return mapping.get(self.colour, 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():
|
def test_tag_metadata_css_and_uniqueness():
|
||||||
tag = Tag.objects.create(name="llms", slug="llms")
|
tag = Tag.objects.create(name="llms", slug="llms")
|
||||||
meta = TagMetadata.objects.create(tag=tag, colour="cyan")
|
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):
|
with pytest.raises(IntegrityError):
|
||||||
TagMetadata.objects.create(tag=tag, colour="pink")
|
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("/")
|
resp = client.get("/")
|
||||||
html = resp.content.decode()
|
html = resp.content.decode()
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert 'name="source" value="nav"' in html
|
# Nav has a Subscribe CTA link (no inline form — wireframe spec)
|
||||||
assert 'name="source" value="footer"' in html
|
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
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
from apps.comments.forms import CommentForm
|
from apps.comments.forms import CommentForm
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ def test_comment_form_rejects_blank_body():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
@override_settings(COMMENT_RATE_LIMIT_PER_MINUTE=3)
|
||||||
def test_comment_rate_limit(client, article_page):
|
def test_comment_rate_limit(client, article_page):
|
||||||
cache.clear()
|
cache.clear()
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ class CommentCreateView(View):
|
|||||||
ip = client_ip_from_request(request)
|
ip = client_ip_from_request(request)
|
||||||
key = f"comment-rate:{ip}"
|
key = f"comment-rate:{ip}"
|
||||||
count = cache.get(key, 0)
|
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)
|
return HttpResponse(status=429)
|
||||||
cache.set(key, count + 1, timeout=60)
|
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.authors.models import Author
|
||||||
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
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
|
from apps.legal.models import LegalIndexPage, LegalPage
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +53,17 @@ class Command(BaseCommand):
|
|||||||
article_index.add_child(instance=article)
|
article_index.add_child(instance=article)
|
||||||
article.save_revision().publish()
|
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")
|
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
|
||||||
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
|
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
|
||||||
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
|
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
|
||||||
@@ -128,4 +140,45 @@ class Command(BaseCommand):
|
|||||||
site.is_default_site = True
|
site.is_default_site = True
|
||||||
site.save()
|
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,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ class SecurityHeadersMiddleware:
|
|||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
|
ADMIN_PREFIXES = ("/cms/", "/django-admin/")
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
nonce = secrets.token_urlsafe(16)
|
nonce = secrets.token_urlsafe(16)
|
||||||
request.csp_nonce = nonce
|
request.csp_nonce = nonce
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
|
if request.path.startswith(self.ADMIN_PREFIXES):
|
||||||
|
return response
|
||||||
response["Content-Security-Policy"] = (
|
response["Content-Security-Policy"] = (
|
||||||
f"default-src 'self'; "
|
f"default-src 'self'; "
|
||||||
f"script-src 'self' 'nonce-{nonce}'; "
|
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 import models
|
||||||
from django.db.models import SET_NULL
|
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.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
|
@register_setting
|
||||||
class SiteSettings(BaseSiteSetting):
|
class SiteSettings(ClusterableModel, BaseSiteSetting):
|
||||||
default_og_image = models.ForeignKey(
|
default_og_image = models.ForeignKey(
|
||||||
"wagtailimages.Image",
|
"wagtailimages.Image",
|
||||||
null=True,
|
null=True,
|
||||||
@@ -19,3 +33,140 @@ class SiteSettings(BaseSiteSetting):
|
|||||||
on_delete=SET_NULL,
|
on_delete=SET_NULL,
|
||||||
related_name="+",
|
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 wagtail.models import Site
|
||||||
|
|
||||||
from apps.blog.models import TagMetadata
|
from apps.blog.models import TagMetadata
|
||||||
|
from apps.core.models import SiteSettings
|
||||||
from apps.legal.models import LegalPage
|
from apps.legal.models import LegalPage
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@@ -20,6 +21,31 @@ def get_legal_pages(context):
|
|||||||
return pages
|
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.simple_tag
|
||||||
@register.filter
|
@register.filter
|
||||||
def get_tag_css(tag):
|
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
|
||||||
@@ -142,6 +142,13 @@ X_CONTENT_TYPE_OPTIONS = "nosniff"
|
|||||||
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
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()]
|
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
|
||||||
|
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
TAILWIND_APP_NAME = "theme"
|
TAILWIND_APP_NAME = "theme"
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ INTERNAL_IPS = ["127.0.0.1"]
|
|||||||
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
|
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
|
||||||
# media URL pattern in urls.py).
|
# media URL pattern in urls.py).
|
||||||
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
||||||
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
STORAGES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||||
|
},
|
||||||
|
"staticfiles": {
|
||||||
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import debug_toolbar # noqa: F401
|
import debug_toolbar # noqa: F401
|
||||||
@@ -18,3 +26,5 @@ try:
|
|||||||
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None:
|
||||||
def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None:
|
"""Fill and submit the main (non-reply) comment form."""
|
||||||
_go_to_article(page, base_url)
|
form = page.locator("form[data-comment-form]")
|
||||||
|
form.locator('input[name="author_name"]').fill(name)
|
||||||
# Fill the main comment form (not a reply form)
|
form.locator('input[name="author_email"]').fill(email)
|
||||||
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
form.locator('textarea[name="body"]').fill(body)
|
||||||
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()
|
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)
|
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
|
@pytest.mark.e2e
|
||||||
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
|
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
|
||||||
_go_to_article(page, base_url)
|
_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"))
|
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
|
||||||
form.locator('input[name="author_name"]').fill("E2E Tester")
|
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('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()
|
form.get_by_role("button", name="Post comment").click()
|
||||||
page.wait_for_load_state("networkidle")
|
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
|
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
|
@pytest.mark.e2e
|
||||||
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
|
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
|
||||||
"""Article with comments_enabled=False must not show the comments section."""
|
"""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, (
|
assert response is not None and response.status == 200, (
|
||||||
f"Expected 200 for e2e-no-comments article, got {response and response.status}"
|
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")
|
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("heading", name="Comments", exact=True)).to_have_count(0)
|
||||||
expect(page.get_by_role("button", name="Post comment")).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
|
@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")
|
page.goto(f"{base_url}/", wait_until="networkidle")
|
||||||
# The nav contains a newsletter form with an email input
|
|
||||||
nav = page.locator("nav")
|
nav = page.locator("nav")
|
||||||
expect(nav.locator('input[type="email"]')).to_be_visible()
|
# Nav has a Subscribe CTA link (not a form — wireframe spec)
|
||||||
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible()
|
expect(nav.get_by_role("link", name="Subscribe")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from playwright.sync_api import Page, expect
|
|||||||
|
|
||||||
|
|
||||||
def _nav_newsletter_form(page: Page):
|
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
|
@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)
|
form = _nav_newsletter_form(page)
|
||||||
# Disable the browser's native HTML5 email validation so the JS handler
|
# Disable the browser's native HTML5 email validation so the JS handler
|
||||||
# fires and sends the bad value to the server (which returns 400).
|
# 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.locator('input[type="email"]').fill("not-an-email")
|
||||||
form.get_by_role("button", name="Subscribe").click()
|
form.get_by_role("button", name="Subscribe").click()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Django~=5.2.0
|
|||||||
wagtail~=7.0.0
|
wagtail~=7.0.0
|
||||||
wagtail-seo~=3.1.1
|
wagtail-seo~=3.1.1
|
||||||
psycopg2-binary~=2.9.0
|
psycopg2-binary~=2.9.0
|
||||||
Pillow~=11.0.0
|
Pillow~=12.1
|
||||||
django-taggit~=6.0.0
|
django-taggit~=6.0.0
|
||||||
whitenoise~=6.0.0
|
whitenoise~=6.0.0
|
||||||
gunicorn~=23.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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{% block title %}No Hype AI{% endblock %}</title>
|
<title>{% block title %}No Hype AI{% endblock %}</title>
|
||||||
{% block head_meta %}{% endblock %}
|
{% 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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
</div>
|
||||||
<textarea name="body" required placeholder="Write a reply..." rows="2"
|
<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>
|
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" />
|
<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>
|
||||||
<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>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -197,7 +196,7 @@
|
|||||||
|
|
||||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
<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>
|
<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 %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -217,7 +216,7 @@
|
|||||||
<textarea name="body" required rows="5"
|
<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>
|
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>
|
</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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
{% load core_tags %}
|
{% 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">
|
<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="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
<div class="md:col-span-2">
|
<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">
|
<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>
|
{{ site_settings.footer_description|default:"In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers."|linebreaksbr }}
|
||||||
Honest analysis for developers.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Navigation</h4>
|
<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">
|
<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>
|
{% for item in footer_nav_items %}
|
||||||
<li><a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a></li>
|
<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>
|
||||||
<li><a href="/about/" class="hover:text-brand-pink transition-colors">About</a></li>
|
{% endfor %}
|
||||||
{% get_legal_pages as legal_pages %}
|
{% get_legal_pages as legal_pages %}
|
||||||
{% for page in legal_pages %}
|
{% for page in legal_pages %}
|
||||||
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
|
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
|
||||||
@@ -21,13 +22,21 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Newsletter</h4>
|
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Connect</h4>
|
||||||
<p class="text-zinc-500 font-mono text-sm mb-4">Get weekly AI tool reviews.</p>
|
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
||||||
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
|
{% 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>
|
</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">
|
<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>© {% now "Y" %} {{ site_settings.copyright_text|default:"No Hype AI. All rights reserved." }}</p>
|
||||||
<p>Honest AI tool reviews for developers.</p>
|
<p>{{ site_settings.tagline|default:"Honest AI tool reviews for developers." }}</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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">
|
<nav class="sticky top-0 z-50 backdrop-blur-md bg-brand-light/80 dark:bg-brand-dark/80 border-b border-zinc-200 dark:border-zinc-800 transition-colors">
|
||||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
@@ -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 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>
|
</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>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop Links -->
|
<!-- Desktop Links -->
|
||||||
<div class="hidden md:flex items-center gap-8 font-medium">
|
<div class="hidden md:flex items-center gap-8 font-medium">
|
||||||
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a>
|
{% for item in header_items %}
|
||||||
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a>
|
<a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||||
<a href="/about/" class="hover:text-brand-pink transition-colors">About</a>
|
{% endfor %}
|
||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="flex items-center gap-2" id="nav-newsletter">
|
<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>
|
||||||
{% 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle + Hamburger -->
|
<!-- Theme Toggle + Hamburger -->
|
||||||
@@ -41,15 +34,15 @@
|
|||||||
<!-- Mobile Menu (outside <nav> to avoid duplicate form[data-newsletter-form] in nav scope) -->
|
<!-- 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 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">
|
<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>
|
{% for item in header_items %}
|
||||||
<a href="/articles/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Articles</a>
|
<a href="{{ item.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||||
<a href="/about/" class="font-medium py-2 hover:text-brand-pink transition-colors">About</a>
|
{% endfor %}
|
||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="source" value="nav-mobile" />
|
<input type="hidden" name="source" value="nav-mobile" />
|
||||||
<input type="email" name="email" required placeholder="dev@example.com"
|
<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" />
|
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>
|
<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>
|
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
|
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
|
||||||
<input type="email" name="email" required placeholder="dev@example.com"
|
<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" />
|
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"
|
<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">
|
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" }}
|
{{ label|default:"Subscribe" }}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module.exports = {
|
|||||||
content: [
|
content: [
|
||||||
"../../templates/**/*.html",
|
"../../templates/**/*.html",
|
||||||
"../../apps/**/templates/**/*.html",
|
"../../apps/**/templates/**/*.html",
|
||||||
|
"../../apps/blog/models.py",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||