fix: nav/footer wireframe alignment, honeypot CSP fix, comment E2E coverage #22
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 @@ 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.legal.models import LegalIndexPage, LegalPage
|
from apps.legal.models import LegalIndexPage, LegalPage
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +52,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()
|
||||||
|
|||||||
@@ -26,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()
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -21,9 +21,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' %}
|
<li>
|
||||||
|
<a href="https://twitter.com/nohypeai" class="hover:text-brand-cyan transition-colors flex items-center justify-center md:justify-start gap-2">
|
||||||
|
<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>
|
||||||
|
Twitter (X)
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/feed/" class="hover:text-brand-cyan transition-colors flex items-center justify-center md:justify-start gap-2">
|
||||||
|
<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>
|
||||||
|
RSS Feed
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -14,15 +14,7 @@
|
|||||||
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a>
|
<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="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a>
|
||||||
<a href="/about/" class="hover:text-brand-pink transition-colors">About</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">
|
<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 -->
|
||||||
@@ -49,7 +41,7 @@
|
|||||||
<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" }}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user