Compare commits
1 Commits
98175e2fc5
...
1c5ba6cf90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5ba6cf90 |
@@ -7,6 +7,7 @@ from wagtail.models import Page, Site
|
||||
from apps.authors.models import Author
|
||||
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
|
||||
from apps.comments.models import Comment
|
||||
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
||||
from apps.legal.models import LegalIndexPage, LegalPage
|
||||
|
||||
|
||||
@@ -139,4 +140,45 @@ class Command(BaseCommand):
|
||||
site.is_default_site = True
|
||||
site.save()
|
||||
|
||||
# Navigation menu items and social links — always reconcile to
|
||||
# match the pages we just created (the data migration may have
|
||||
# seeded partial items before these pages existed).
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
NavigationMenuItem.objects.filter(settings=settings).delete()
|
||||
article_index_page = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
|
||||
about_page = AboutPage.objects.child_of(home).filter(slug="about").first()
|
||||
nav_items = [
|
||||
NavigationMenuItem(settings=settings, link_page=home, link_title="Home", sort_order=0),
|
||||
]
|
||||
if article_index_page:
|
||||
nav_items.append(
|
||||
NavigationMenuItem(
|
||||
settings=settings, link_page=article_index_page,
|
||||
link_title="Articles", sort_order=1,
|
||||
)
|
||||
)
|
||||
if about_page:
|
||||
nav_items.append(
|
||||
NavigationMenuItem(
|
||||
settings=settings, link_page=about_page,
|
||||
link_title="About", sort_order=2,
|
||||
)
|
||||
)
|
||||
NavigationMenuItem.objects.bulk_create(nav_items)
|
||||
|
||||
SocialMediaLink.objects.filter(settings=settings).delete()
|
||||
SocialMediaLink.objects.bulk_create(
|
||||
[
|
||||
SocialMediaLink(
|
||||
settings=settings, platform="twitter",
|
||||
url="https://twitter.com/nohypeai",
|
||||
label="Twitter (X)", sort_order=0,
|
||||
),
|
||||
SocialMediaLink(
|
||||
settings=settings, platform="rss",
|
||||
url="/feed/", label="RSS Feed", sort_order=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-02 18:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import modelcluster.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
('wagtailcore', '0094_alter_page_locale'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitesettings',
|
||||
name='copyright_text',
|
||||
field=models.CharField(default='No Hype AI. All rights reserved.', max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitesettings',
|
||||
name='footer_description',
|
||||
field=models.TextField(blank=True, default='In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitesettings',
|
||||
name='site_name',
|
||||
field=models.CharField(default='NO HYPE AI', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitesettings',
|
||||
name='tagline',
|
||||
field=models.CharField(default='Honest AI tool reviews for developers.', max_length=200),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NavigationMenuItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
||||
('link_url', models.URLField(blank=True, default='', help_text='External URL (used only when no page is selected).')),
|
||||
('link_title', models.CharField(blank=True, default='', help_text='Override the display text. If blank, the page title is used.', max_length=100)),
|
||||
('open_in_new_tab', models.BooleanField(default=False)),
|
||||
('show_in_header', models.BooleanField(default=True)),
|
||||
('show_in_footer', models.BooleanField(default=True)),
|
||||
('link_page', models.ForeignKey(blank=True, help_text='Link to an internal page. If unpublished, the link is hidden automatically.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
|
||||
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='navigation_items', to='core.sitesettings')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SocialMediaLink',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
||||
('platform', models.CharField(choices=[('twitter', 'Twitter / X'), ('github', 'GitHub'), ('rss', 'RSS Feed'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube'), ('mastodon', 'Mastodon'), ('bluesky', 'Bluesky')], max_length=30)),
|
||||
('url', models.URLField()),
|
||||
('label', models.CharField(blank=True, default='', help_text='Display label. If blank, the platform name is used.', max_length=100)),
|
||||
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_links', to='core.sitesettings')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order'],
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
112
apps/core/migrations/0003_seed_navigation_data.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-02 18:39
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed_navigation_data(apps, schema_editor):
|
||||
Site = apps.get_model("wagtailcore", "Site")
|
||||
SiteSettings = apps.get_model("core", "SiteSettings")
|
||||
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
|
||||
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
|
||||
Page = apps.get_model("wagtailcore", "Page")
|
||||
|
||||
for site in Site.objects.all():
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
|
||||
# Only seed if no nav items exist yet
|
||||
if NavigationMenuItem.objects.filter(settings=settings).exists():
|
||||
continue
|
||||
|
||||
root_page = site.root_page
|
||||
if not root_page:
|
||||
continue
|
||||
|
||||
# Find pages by slug under the site root using tree path
|
||||
home_page = root_page
|
||||
# In Wagtail's treebeard, direct children share the root's path prefix
|
||||
articles_page = Page.objects.filter(
|
||||
depth=root_page.depth + 1,
|
||||
path__startswith=root_page.path,
|
||||
slug__startswith="articles",
|
||||
).first()
|
||||
about_page = Page.objects.filter(
|
||||
depth=root_page.depth + 1,
|
||||
path__startswith=root_page.path,
|
||||
slug__startswith="about",
|
||||
).first()
|
||||
|
||||
nav_items = []
|
||||
if home_page:
|
||||
nav_items.append(
|
||||
NavigationMenuItem(
|
||||
settings=settings,
|
||||
link_page=home_page,
|
||||
link_title="Home",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=0,
|
||||
)
|
||||
)
|
||||
if articles_page:
|
||||
nav_items.append(
|
||||
NavigationMenuItem(
|
||||
settings=settings,
|
||||
link_page=articles_page,
|
||||
link_title="Articles",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=1,
|
||||
)
|
||||
)
|
||||
if about_page:
|
||||
nav_items.append(
|
||||
NavigationMenuItem(
|
||||
settings=settings,
|
||||
link_page=about_page,
|
||||
link_title="About",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=2,
|
||||
)
|
||||
)
|
||||
NavigationMenuItem.objects.bulk_create(nav_items)
|
||||
|
||||
# Social links
|
||||
if not SocialMediaLink.objects.filter(settings=settings).exists():
|
||||
SocialMediaLink.objects.bulk_create(
|
||||
[
|
||||
SocialMediaLink(
|
||||
settings=settings,
|
||||
platform="twitter",
|
||||
url="https://twitter.com/nohypeai",
|
||||
label="Twitter (X)",
|
||||
sort_order=0,
|
||||
),
|
||||
SocialMediaLink(
|
||||
settings=settings,
|
||||
platform="rss",
|
||||
url="/feed/",
|
||||
label="RSS Feed",
|
||||
sort_order=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def reverse_seed(apps, schema_editor):
|
||||
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
|
||||
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
|
||||
NavigationMenuItem.objects.all().delete()
|
||||
SocialMediaLink.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_sitesettings_copyright_text_and_more'),
|
||||
('wagtailcore', '0094_alter_page_locale'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_navigation_data, reverse_seed),
|
||||
]
|
||||
@@ -1,10 +1,24 @@
|
||||
from django.db import models
|
||||
from django.db.models import SET_NULL
|
||||
from modelcluster.fields import ParentalKey
|
||||
from modelcluster.models import ClusterableModel
|
||||
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
|
||||
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
|
||||
from wagtail.models import Orderable
|
||||
|
||||
SOCIAL_ICON_CHOICES = [
|
||||
("twitter", "Twitter / X"),
|
||||
("github", "GitHub"),
|
||||
("rss", "RSS Feed"),
|
||||
("linkedin", "LinkedIn"),
|
||||
("youtube", "YouTube"),
|
||||
("mastodon", "Mastodon"),
|
||||
("bluesky", "Bluesky"),
|
||||
]
|
||||
|
||||
|
||||
@register_setting
|
||||
class SiteSettings(BaseSiteSetting):
|
||||
class SiteSettings(ClusterableModel, BaseSiteSetting):
|
||||
default_og_image = models.ForeignKey(
|
||||
"wagtailimages.Image",
|
||||
null=True,
|
||||
@@ -19,3 +33,140 @@ class SiteSettings(BaseSiteSetting):
|
||||
on_delete=SET_NULL,
|
||||
related_name="+",
|
||||
)
|
||||
|
||||
# Branding
|
||||
site_name = models.CharField(max_length=100, default="NO HYPE AI")
|
||||
tagline = models.CharField(
|
||||
max_length=200,
|
||||
default="Honest AI tool reviews for developers.",
|
||||
)
|
||||
footer_description = models.TextField(
|
||||
default="In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.",
|
||||
blank=True,
|
||||
)
|
||||
copyright_text = models.CharField(
|
||||
max_length=200,
|
||||
default="No Hype AI. All rights reserved.",
|
||||
)
|
||||
|
||||
panels = [
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("site_name"),
|
||||
FieldPanel("tagline"),
|
||||
FieldPanel("footer_description"),
|
||||
FieldPanel("copyright_text"),
|
||||
],
|
||||
heading="Branding",
|
||||
),
|
||||
MultiFieldPanel(
|
||||
[
|
||||
FieldPanel("default_og_image"),
|
||||
FieldPanel("privacy_policy_page"),
|
||||
],
|
||||
heading="SEO & Legal",
|
||||
),
|
||||
InlinePanel("navigation_items", label="Navigation Menu Items"),
|
||||
InlinePanel("social_links", label="Social Media Links"),
|
||||
]
|
||||
|
||||
|
||||
class NavigationMenuItem(Orderable):
|
||||
settings = ParentalKey(
|
||||
SiteSettings,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="navigation_items",
|
||||
)
|
||||
link_page = models.ForeignKey(
|
||||
"wagtailcore.Page",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=SET_NULL,
|
||||
related_name="+",
|
||||
help_text="Link to an internal page. If unpublished, the link is hidden automatically.",
|
||||
)
|
||||
link_url = models.URLField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="External URL (used only when no page is selected).",
|
||||
)
|
||||
link_title = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Override the display text. If blank, the page title is used.",
|
||||
)
|
||||
open_in_new_tab = models.BooleanField(default=False)
|
||||
show_in_header = models.BooleanField(default=True)
|
||||
show_in_footer = models.BooleanField(default=True)
|
||||
|
||||
panels = [
|
||||
FieldPanel("link_page"),
|
||||
FieldPanel("link_url"),
|
||||
FieldPanel("link_title"),
|
||||
FieldPanel("open_in_new_tab"),
|
||||
FieldPanel("show_in_header"),
|
||||
FieldPanel("show_in_footer"),
|
||||
]
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
if self.link_title:
|
||||
return self.link_title
|
||||
if self.link_page:
|
||||
return self.link_page.title
|
||||
return ""
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
if self.link_page:
|
||||
return self.link_page.url
|
||||
return self.link_url
|
||||
|
||||
@property
|
||||
def is_live(self):
|
||||
"""Return False if linked to an unpublished/non-live page."""
|
||||
if self.link_page_id:
|
||||
return self.link_page.live
|
||||
return bool(self.link_url)
|
||||
|
||||
class Meta(Orderable.Meta):
|
||||
pass
|
||||
|
||||
|
||||
class SocialMediaLink(Orderable):
|
||||
settings = ParentalKey(
|
||||
SiteSettings,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="social_links",
|
||||
)
|
||||
platform = models.CharField(
|
||||
max_length=30,
|
||||
choices=SOCIAL_ICON_CHOICES,
|
||||
)
|
||||
url = models.URLField()
|
||||
label = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Display label. If blank, the platform name is used.",
|
||||
)
|
||||
|
||||
panels = [
|
||||
FieldPanel("platform"),
|
||||
FieldPanel("url"),
|
||||
FieldPanel("label"),
|
||||
]
|
||||
|
||||
@property
|
||||
def display_label(self):
|
||||
if self.label:
|
||||
return self.label
|
||||
return dict(SOCIAL_ICON_CHOICES).get(self.platform, self.platform)
|
||||
|
||||
@property
|
||||
def icon_template(self):
|
||||
return f"components/icons/{self.platform}.html"
|
||||
|
||||
class Meta(Orderable.Meta):
|
||||
pass
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import TagMetadata
|
||||
from apps.core.models import SiteSettings
|
||||
from apps.legal.models import LegalPage
|
||||
|
||||
register = template.Library()
|
||||
@@ -20,6 +21,31 @@ def get_legal_pages(context):
|
||||
return pages
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def get_nav_items(context, location="header"):
|
||||
request = context.get("request")
|
||||
site = Site.find_for_request(request) if request else None
|
||||
settings = SiteSettings.for_site(site) if site else None
|
||||
if not settings:
|
||||
return []
|
||||
items = settings.navigation_items.all()
|
||||
if location == "header":
|
||||
items = items.filter(show_in_header=True)
|
||||
elif location == "footer":
|
||||
items = items.filter(show_in_footer=True)
|
||||
return [item for item in items if item.is_live]
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def get_social_links(context):
|
||||
request = context.get("request")
|
||||
site = Site.find_for_request(request) if request else None
|
||||
settings = SiteSettings.for_site(site) if site else None
|
||||
if not settings:
|
||||
return []
|
||||
return list(settings.social_links.all())
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
@register.filter
|
||||
def get_tag_css(tag):
|
||||
|
||||
191
apps/core/tests/test_navigation.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import pytest
|
||||
from wagtail.models import Site
|
||||
|
||||
from apps.blog.models import AboutPage, ArticleIndexPage
|
||||
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def site_with_nav(home_page):
|
||||
"""Create SiteSettings with nav items and social links for testing."""
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
|
||||
# Clear any items seeded by the data migration
|
||||
settings.navigation_items.all().delete()
|
||||
settings.social_links.all().delete()
|
||||
|
||||
# Create article index and about page
|
||||
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
||||
home_page.add_child(instance=article_index)
|
||||
article_index.save_revision().publish()
|
||||
|
||||
about = AboutPage(
|
||||
title="About",
|
||||
slug="about",
|
||||
mission_statement="Test mission",
|
||||
body="<p>About page</p>",
|
||||
)
|
||||
home_page.add_child(instance=about)
|
||||
about.save_revision().publish()
|
||||
|
||||
# Create nav items
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=settings,
|
||||
link_page=home_page,
|
||||
link_title="Home",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=0,
|
||||
)
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=settings,
|
||||
link_page=article_index,
|
||||
link_title="Articles",
|
||||
show_in_header=True,
|
||||
show_in_footer=True,
|
||||
sort_order=1,
|
||||
)
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=settings,
|
||||
link_page=about,
|
||||
link_title="About",
|
||||
show_in_header=True,
|
||||
show_in_footer=False,
|
||||
sort_order=2,
|
||||
)
|
||||
|
||||
# Social links
|
||||
SocialMediaLink.objects.create(
|
||||
settings=settings,
|
||||
platform="twitter",
|
||||
url="https://twitter.com/nohypeai",
|
||||
label="Twitter (X)",
|
||||
sort_order=0,
|
||||
)
|
||||
SocialMediaLink.objects.create(
|
||||
settings=settings,
|
||||
platform="rss",
|
||||
url="/feed/",
|
||||
label="RSS Feed",
|
||||
sort_order=1,
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestNavigationMenuItem:
|
||||
def test_live_page_is_rendered(self, site_with_nav):
|
||||
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
||||
assert len(items) == 3
|
||||
|
||||
def test_unpublished_page_excluded(self, site_with_nav):
|
||||
about_item = site_with_nav.navigation_items.get(link_title="About")
|
||||
about_item.link_page.unpublish()
|
||||
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
||||
assert len(items) == 2
|
||||
assert all(i.link_title != "About" for i in items)
|
||||
|
||||
def test_external_url_item(self, site_with_nav):
|
||||
NavigationMenuItem.objects.create(
|
||||
settings=site_with_nav,
|
||||
link_url="https://example.com",
|
||||
link_title="External",
|
||||
sort_order=10,
|
||||
)
|
||||
item = site_with_nav.navigation_items.get(link_title="External")
|
||||
assert item.is_live is True
|
||||
assert item.url == "https://example.com"
|
||||
assert item.title == "External"
|
||||
|
||||
def test_title_falls_back_to_page_title(self, site_with_nav):
|
||||
item = site_with_nav.navigation_items.get(sort_order=0)
|
||||
item.link_title = ""
|
||||
item.save()
|
||||
assert item.title == item.link_page.title
|
||||
|
||||
def test_header_footer_filtering(self, site_with_nav):
|
||||
header_items = site_with_nav.navigation_items.filter(show_in_header=True)
|
||||
footer_items = site_with_nav.navigation_items.filter(show_in_footer=True)
|
||||
assert header_items.count() == 3
|
||||
assert footer_items.count() == 2 # About excluded from footer
|
||||
|
||||
def test_sort_order_respected(self, site_with_nav):
|
||||
items = list(site_with_nav.navigation_items.all().order_by("sort_order"))
|
||||
assert [i.link_title for i in items] == ["Home", "Articles", "About"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSocialMediaLink:
|
||||
def test_display_label_from_field(self, site_with_nav):
|
||||
link = site_with_nav.social_links.get(platform="twitter")
|
||||
assert link.display_label == "Twitter (X)"
|
||||
|
||||
def test_display_label_fallback(self, site_with_nav):
|
||||
link = site_with_nav.social_links.get(platform="twitter")
|
||||
link.label = ""
|
||||
assert link.display_label == "Twitter / X"
|
||||
|
||||
def test_icon_template_path(self, site_with_nav):
|
||||
link = site_with_nav.social_links.get(platform="rss")
|
||||
assert link.icon_template == "components/icons/rss.html"
|
||||
|
||||
def test_ordering(self, site_with_nav):
|
||||
links = list(site_with_nav.social_links.all().order_by("sort_order"))
|
||||
assert [link.platform for link in links] == ["twitter", "rss"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSiteSettingsDefaults:
|
||||
def test_default_site_name(self, home_page):
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
assert settings.site_name == "NO HYPE AI"
|
||||
|
||||
def test_default_copyright(self, home_page):
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
assert settings.copyright_text == "No Hype AI. All rights reserved."
|
||||
|
||||
def test_default_tagline(self, home_page):
|
||||
site = Site.objects.get(is_default_site=True)
|
||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
||||
assert settings.tagline == "Honest AI tool reviews for developers."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestNavRendering:
|
||||
def test_header_shows_nav_items(self, client, site_with_nav):
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
assert "Home" in content
|
||||
assert "Articles" in content
|
||||
assert "About" in content
|
||||
|
||||
def test_unpublished_page_not_in_header(self, client, site_with_nav):
|
||||
about_item = site_with_nav.navigation_items.get(link_title="About")
|
||||
about_item.link_page.unpublish()
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
# About should not appear as a nav link (but might appear elsewhere on page)
|
||||
assert 'href="/about/"' not in content
|
||||
|
||||
def test_footer_shows_nav_items(self, client, site_with_nav):
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
# Footer should have social links
|
||||
assert "Twitter (X)" in content
|
||||
assert "RSS Feed" in content
|
||||
|
||||
def test_footer_shows_branding(self, client, site_with_nav):
|
||||
site_with_nav.site_name = "TEST SITE"
|
||||
site_with_nav.save()
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
assert "TEST SITE" in content
|
||||
|
||||
def test_footer_shows_copyright(self, client, site_with_nav):
|
||||
resp = client.get("/")
|
||||
content = resp.content.decode()
|
||||
assert "No Hype AI. All rights reserved." in content
|
||||
@@ -1,19 +1,20 @@
|
||||
{% load core_tags %}
|
||||
{% get_nav_items "footer" as footer_nav_items %}
|
||||
{% get_social_links as social_links %}
|
||||
<footer class="border-t border-zinc-200 dark:border-zinc-800 bg-brand-light dark:bg-brand-dark mt-12 py-12 text-center md:text-left">
|
||||
<div class="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div class="md:col-span-2">
|
||||
<a href="/" class="font-display font-bold text-2xl tracking-tight mb-4 inline-block">NO HYPE AI</a>
|
||||
<a href="/" class="font-display font-bold text-2xl tracking-tight mb-4 inline-block">{{ site_settings.site_name|default:"NO HYPE AI" }}</a>
|
||||
<p class="text-zinc-500 font-mono text-sm max-w-sm mx-auto md:mx-0">
|
||||
In-depth reviews and benchmarks of the latest AI coding tools.<br>
|
||||
Honest analysis for developers.
|
||||
{{ site_settings.footer_description|default:"In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers."|linebreaksbr }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Navigation</h4>
|
||||
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
||||
<li><a href="/" class="hover:text-brand-cyan transition-colors">Home</a></li>
|
||||
<li><a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a></li>
|
||||
<li><a href="/about/" class="hover:text-brand-pink transition-colors">About</a></li>
|
||||
{% for item in footer_nav_items %}
|
||||
<li><a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a></li>
|
||||
{% endfor %}
|
||||
{% get_legal_pages as legal_pages %}
|
||||
{% for page in legal_pages %}
|
||||
<li><a href="{{ page.url }}" class="hover:text-brand-pink transition-colors">{{ page.title }}</a></li>
|
||||
@@ -23,23 +24,19 @@
|
||||
<div>
|
||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Connect</h4>
|
||||
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
||||
{% for link in social_links %}
|
||||
<li>
|
||||
<a href="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 href="{{ link.url }}" class="hover:text-brand-cyan transition-colors flex items-center justify-center md:justify-start gap-2"{% if link.url != "/feed/" %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
{% include link.icon_template %}
|
||||
{{ link.display_label }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800 text-center font-mono text-xs text-zinc-500 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p>© {% now "Y" %} No Hype AI. All rights reserved.</p>
|
||||
<p>Honest AI tool reviews for developers.</p>
|
||||
<p>© {% now "Y" %} {{ site_settings.copyright_text|default:"No Hype AI. All rights reserved." }}</p>
|
||||
<p>{{ site_settings.tagline|default:"Honest AI tool reviews for developers." }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
1
templates/components/icons/bluesky.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Z" /></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
1
templates/components/icons/github.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>
|
||||
|
After Width: | Height: | Size: 266 B |
1
templates/components/icons/linkedin.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
|
||||
|
After Width: | Height: | Size: 783 B |
1
templates/components/icons/mastodon.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>
|
||||
|
After Width: | Height: | Size: 671 B |
1
templates/components/icons/rss.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
|
||||
|
After Width: | Height: | Size: 330 B |
1
templates/components/icons/twitter.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
|
||||
|
After Width: | Height: | Size: 919 B |
1
templates/components/icons/youtube.html
Normal file
@@ -0,0 +1 @@
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-8.9a.75.75 0 0 0-.53-1.1H13.5l-1.5 3-1.5-3H4.06a.75.75 0 0 0-.53 1.1l4.72 8.9-4.72 8.9a.75.75 0 0 0 .53 1.1H10.5l1.5-3 1.5 3h6.44a.75.75 0 0 0 .53-1.1l-4.72-8.9Z" /></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -1,4 +1,5 @@
|
||||
{% load static %}
|
||||
{% load static core_tags %}
|
||||
{% get_nav_items "header" as header_items %}
|
||||
<nav class="sticky top-0 z-50 backdrop-blur-md bg-brand-light/80 dark:bg-brand-dark/80 border-b border-zinc-200 dark:border-zinc-800 transition-colors">
|
||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
@@ -6,14 +7,14 @@
|
||||
<div class="w-8 h-8 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark flex items-center justify-center font-display font-bold text-xl group-hover:rotate-12 transition-transform">
|
||||
/
|
||||
</div>
|
||||
<span class="font-display font-bold text-2xl tracking-tight">NO HYPE AI</span>
|
||||
<span class="font-display font-bold text-2xl tracking-tight">{{ site_settings.site_name|default:"NO HYPE AI" }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Links -->
|
||||
<div class="hidden md:flex items-center gap-8 font-medium">
|
||||
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a>
|
||||
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a>
|
||||
<a href="/about/" class="hover:text-brand-pink transition-colors">About</a>
|
||||
{% for item in header_items %}
|
||||
<a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
|
||||
</div>
|
||||
|
||||
@@ -33,9 +34,9 @@
|
||||
<!-- Mobile Menu (outside <nav> to avoid duplicate form[data-newsletter-form] in nav scope) -->
|
||||
<div id="mobile-menu" class="md:hidden hidden sticky top-20 z-40 border-b border-zinc-200 dark:border-zinc-800 bg-brand-light/95 dark:bg-brand-dark/95 backdrop-blur-md">
|
||||
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col gap-4">
|
||||
<a href="/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Home</a>
|
||||
<a href="/articles/" class="font-medium py-2 hover:text-brand-cyan transition-colors">Articles</a>
|
||||
<a href="/about/" class="font-medium py-2 hover:text-brand-pink transition-colors">About</a>
|
||||
{% for item in header_items %}
|
||||
<a href="{{ item.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="source" value="nav-mobile" />
|
||||
|
||||