Compare commits
1 Commits
98175e2fc5
...
fix/dev-wh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800475cf36
|
@@ -2,9 +2,6 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
|
|
||||||
@@ -191,15 +188,3 @@ jobs:
|
|||||||
- name: Remove CI image
|
- name: Remove CI image
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image rm -f "$CI_IMAGE" || true
|
run: docker image rm -f "$CI_IMAGE" || true
|
||||||
|
|
||||||
deploy:
|
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Deploy to lintel-prod-01
|
|
||||||
uses: appleboy/ssh-action@v1
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.PROD_SSH_HOST }}
|
|
||||||
username: deploy
|
|
||||||
key: ${{ secrets.PROD_SSH_KEY }}
|
|
||||||
script: bash /srv/sum/nohype/app/deploy/deploy.sh
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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
@@ -1,123 +0,0 @@
|
|||||||
DC = docker compose -f /srv/sum/nohype/docker-compose.prod.yml
|
|
||||||
WEB = $(DC) exec web
|
|
||||||
MANAGE = $(WEB) python manage.py
|
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
|
||||||
|
|
||||||
# ── Help ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.PHONY: help
|
|
||||||
help:
|
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
|
||||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' \
|
|
||||||
| sort
|
|
||||||
|
|
||||||
# ── Docker ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.PHONY: build
|
|
||||||
build: ## Build / rebuild images
|
|
||||||
$(DC) build
|
|
||||||
|
|
||||||
.PHONY: up
|
|
||||||
up: ## Start services (detached)
|
|
||||||
$(DC) up -d
|
|
||||||
|
|
||||||
.PHONY: run
|
|
||||||
run: ## Start services in foreground (with logs)
|
|
||||||
$(DC) up
|
|
||||||
|
|
||||||
.PHONY: down
|
|
||||||
down: ## Stop and remove containers
|
|
||||||
$(DC) down
|
|
||||||
|
|
||||||
.PHONY: restart
|
|
||||||
restart: ## Restart all services
|
|
||||||
$(DC) restart
|
|
||||||
|
|
||||||
.PHONY: logs
|
|
||||||
logs: ## Tail logs for all services (Ctrl-C to stop)
|
|
||||||
$(DC) logs -f
|
|
||||||
|
|
||||||
.PHONY: logs-web
|
|
||||||
logs-web: ## Tail web service logs
|
|
||||||
$(DC) logs -f web
|
|
||||||
|
|
||||||
.PHONY: ps
|
|
||||||
ps: ## Show running containers
|
|
||||||
$(DC) ps
|
|
||||||
|
|
||||||
# ── Django ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.PHONY: migrate
|
|
||||||
migrate: ## Apply database migrations
|
|
||||||
$(MANAGE) migrate --noinput
|
|
||||||
|
|
||||||
.PHONY: makemigrations
|
|
||||||
makemigrations: ## Create new migrations (pass app= to target an app)
|
|
||||||
$(MANAGE) makemigrations $(app)
|
|
||||||
|
|
||||||
.PHONY: showmigrations
|
|
||||||
showmigrations: ## List all migrations and their status
|
|
||||||
$(MANAGE) showmigrations
|
|
||||||
|
|
||||||
.PHONY: createsuperuser
|
|
||||||
createsuperuser: ## Create a Django superuser interactively
|
|
||||||
$(MANAGE) createsuperuser
|
|
||||||
|
|
||||||
.PHONY: collectstatic
|
|
||||||
collectstatic: ## Collect static files
|
|
||||||
$(MANAGE) collectstatic --noinput
|
|
||||||
|
|
||||||
.PHONY: shell
|
|
||||||
shell: ## Open a Django shell (inside the web container)
|
|
||||||
$(MANAGE) shell
|
|
||||||
|
|
||||||
.PHONY: dbshell
|
|
||||||
dbshell: ## Open a Django database shell
|
|
||||||
$(MANAGE) dbshell
|
|
||||||
|
|
||||||
.PHONY: bash
|
|
||||||
bash: ## Open a bash shell inside the web container
|
|
||||||
$(WEB) bash
|
|
||||||
|
|
||||||
.PHONY: psql
|
|
||||||
psql: ## Open a psql shell in the db container
|
|
||||||
$(DC) exec db psql -U nohype -d nohype
|
|
||||||
|
|
||||||
# ── Tailwind ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.PHONY: tailwind-install
|
|
||||||
tailwind-install: ## Install Tailwind npm dependencies
|
|
||||||
$(MANAGE) tailwind install --no-input
|
|
||||||
|
|
||||||
.PHONY: tailwind-build
|
|
||||||
tailwind-build: ## Build Tailwind CSS
|
|
||||||
$(MANAGE) tailwind build
|
|
||||||
|
|
||||||
.PHONY: tailwind-watch
|
|
||||||
tailwind-watch: ## Watch and rebuild Tailwind CSS on changes
|
|
||||||
$(MANAGE) tailwind start
|
|
||||||
|
|
||||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
test: ## Run unit/integration tests with pytest
|
|
||||||
$(DC) exec web pytest $(args)
|
|
||||||
|
|
||||||
.PHONY: test-e2e
|
|
||||||
test-e2e: ## Run Playwright E2E tests
|
|
||||||
$(DC) exec web pytest e2e/ $(args)
|
|
||||||
|
|
||||||
# ── Custom management commands ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
.PHONY: seed
|
|
||||||
seed: ## Seed deterministic E2E content
|
|
||||||
$(MANAGE) seed_e2e_content
|
|
||||||
|
|
||||||
.PHONY: check-content
|
|
||||||
check-content: ## Validate live content integrity
|
|
||||||
$(MANAGE) check_content_integrity
|
|
||||||
|
|
||||||
.PHONY: purge-comments
|
|
||||||
purge-comments: ## Purge old comment personal data (pass months=N to override default 24)
|
|
||||||
$(MANAGE) purge_old_comment_data $(if $(months),--months $(months),)
|
|
||||||
@@ -42,11 +42,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -100,12 +95,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-800 dark:bg-zinc-100", "text": "text-white dark:text-black"}
|
return {"bg": "bg-zinc-100", "text": "text-zinc-800"}
|
||||||
|
|
||||||
def get_css_classes(self) -> dict[str, str]:
|
def get_css_classes(self) -> dict[str, str]:
|
||||||
mapping = {
|
mapping = {
|
||||||
"cyan": {"bg": "bg-brand-cyan/10", "text": "text-brand-cyan"},
|
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"},
|
||||||
"pink": {"bg": "bg-brand-pink/10", "text": "text-brand-pink"},
|
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"},
|
||||||
"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"] == "bg-brand-cyan/10"
|
assert meta.get_css_classes()["bg"].startswith("bg-cyan")
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
TagMetadata.objects.create(tag=tag, colour="pink")
|
TagMetadata.objects.create(tag=tag, colour="pink")
|
||||||
|
|||||||
@@ -69,12 +69,8 @@ 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
|
||||||
# Nav has a Subscribe CTA link (no inline form — wireframe spec)
|
assert 'name="source" value="nav"' in html
|
||||||
assert 'href="#newsletter"' in html
|
assert 'name="source" value="footer"' 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,6 +1,5 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -12,7 +11,6 @@ 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,8 +33,7 @@ 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)
|
||||||
rate_limit = getattr(settings, "COMMENT_RATE_LIMIT_PER_MINUTE", 3)
|
if count >= 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,8 +6,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -53,17 +51,7 @@ class Command(BaseCommand):
|
|||||||
article_index.add_child(instance=article)
|
article_index.add_child(instance=article)
|
||||||
article.save_revision().publish()
|
article.save_revision().publish()
|
||||||
|
|
||||||
# Seed one approved top-level comment on the primary article for reply E2E tests
|
# Tagged article — used by tag-filter 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()
|
||||||
@@ -140,40 +128,4 @@ class Command(BaseCommand):
|
|||||||
site.is_default_site = True
|
site.is_default_site = True
|
||||||
site.save()
|
site.save()
|
||||||
|
|
||||||
# Navigation menu items and social links
|
|
||||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
|
||||||
if not NavigationMenuItem.objects.filter(settings=settings).exists():
|
|
||||||
article_index_page = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
|
|
||||||
about_page = AboutPage.objects.child_of(home).filter(slug="about").first()
|
|
||||||
nav_items = [
|
|
||||||
NavigationMenuItem(settings=settings, link_page=home, link_title="Home", sort_order=0),
|
|
||||||
]
|
|
||||||
if article_index_page:
|
|
||||||
nav_items.append(
|
|
||||||
NavigationMenuItem(
|
|
||||||
settings=settings, link_page=article_index_page,
|
|
||||||
link_title="Articles", sort_order=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if about_page:
|
|
||||||
nav_items.append(
|
|
||||||
NavigationMenuItem(settings=settings, link_page=about_page, link_title="About", sort_order=2)
|
|
||||||
)
|
|
||||||
NavigationMenuItem.objects.bulk_create(nav_items)
|
|
||||||
|
|
||||||
if not SocialMediaLink.objects.filter(settings=settings).exists():
|
|
||||||
SocialMediaLink.objects.bulk_create(
|
|
||||||
[
|
|
||||||
SocialMediaLink(
|
|
||||||
settings=settings, platform="twitter",
|
|
||||||
url="https://twitter.com/nohypeai",
|
|
||||||
label="Twitter (X)", sort_order=0,
|
|
||||||
),
|
|
||||||
SocialMediaLink(
|
|
||||||
settings=settings, platform="rss",
|
|
||||||
url="/feed/", label="RSS Feed", sort_order=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
self.stdout.write(self.style.SUCCESS("Seeded E2E content."))
|
||||||
|
|||||||
@@ -18,20 +18,16 @@ 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}'; "
|
||||||
"style-src 'self' https://fonts.googleapis.com; "
|
"style-src 'self'; "
|
||||||
"img-src 'self' data: blob:; "
|
"img-src 'self' data: blob:; "
|
||||||
"font-src 'self' https://fonts.gstatic.com; "
|
"font-src 'self'; "
|
||||||
"connect-src 'self'; "
|
"connect-src 'self'; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Generated by Django 5.2.11 on 2026-03-02 18:39
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
import modelcluster.fields
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0001_initial'),
|
|
||||||
('wagtailcore', '0094_alter_page_locale'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sitesettings',
|
|
||||||
name='copyright_text',
|
|
||||||
field=models.CharField(default='No Hype AI. All rights reserved.', max_length=200),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sitesettings',
|
|
||||||
name='footer_description',
|
|
||||||
field=models.TextField(blank=True, default='In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sitesettings',
|
|
||||||
name='site_name',
|
|
||||||
field=models.CharField(default='NO HYPE AI', max_length=100),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='sitesettings',
|
|
||||||
name='tagline',
|
|
||||||
field=models.CharField(default='Honest AI tool reviews for developers.', max_length=200),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='NavigationMenuItem',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
|
||||||
('link_url', models.URLField(blank=True, default='', help_text='External URL (used only when no page is selected).')),
|
|
||||||
('link_title', models.CharField(blank=True, default='', help_text='Override the display text. If blank, the page title is used.', max_length=100)),
|
|
||||||
('open_in_new_tab', models.BooleanField(default=False)),
|
|
||||||
('show_in_header', models.BooleanField(default=True)),
|
|
||||||
('show_in_footer', models.BooleanField(default=True)),
|
|
||||||
('link_page', models.ForeignKey(blank=True, help_text='Link to an internal page. If unpublished, the link is hidden automatically.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
|
|
||||||
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='navigation_items', to='core.sitesettings')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['sort_order'],
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='SocialMediaLink',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
|
|
||||||
('platform', models.CharField(choices=[('twitter', 'Twitter / X'), ('github', 'GitHub'), ('rss', 'RSS Feed'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube'), ('mastodon', 'Mastodon'), ('bluesky', 'Bluesky')], max_length=30)),
|
|
||||||
('url', models.URLField()),
|
|
||||||
('label', models.CharField(blank=True, default='', help_text='Display label. If blank, the platform name is used.', max_length=100)),
|
|
||||||
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_links', to='core.sitesettings')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['sort_order'],
|
|
||||||
'abstract': False,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# Generated by Django 5.2.11 on 2026-03-02 18:39
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
def seed_navigation_data(apps, schema_editor):
|
|
||||||
Site = apps.get_model("wagtailcore", "Site")
|
|
||||||
SiteSettings = apps.get_model("core", "SiteSettings")
|
|
||||||
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
|
|
||||||
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
|
|
||||||
Page = apps.get_model("wagtailcore", "Page")
|
|
||||||
|
|
||||||
for site in Site.objects.all():
|
|
||||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
|
||||||
|
|
||||||
# Only seed if no nav items exist yet
|
|
||||||
if NavigationMenuItem.objects.filter(settings=settings).exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
root_page = site.root_page
|
|
||||||
if not root_page:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Find pages by slug under the site root using tree path
|
|
||||||
home_page = root_page
|
|
||||||
# In Wagtail's treebeard, direct children share the root's path prefix
|
|
||||||
articles_page = Page.objects.filter(
|
|
||||||
depth=root_page.depth + 1,
|
|
||||||
path__startswith=root_page.path,
|
|
||||||
slug__startswith="articles",
|
|
||||||
).first()
|
|
||||||
about_page = Page.objects.filter(
|
|
||||||
depth=root_page.depth + 1,
|
|
||||||
path__startswith=root_page.path,
|
|
||||||
slug__startswith="about",
|
|
||||||
).first()
|
|
||||||
|
|
||||||
nav_items = []
|
|
||||||
if home_page:
|
|
||||||
nav_items.append(
|
|
||||||
NavigationMenuItem(
|
|
||||||
settings=settings,
|
|
||||||
link_page=home_page,
|
|
||||||
link_title="Home",
|
|
||||||
show_in_header=True,
|
|
||||||
show_in_footer=True,
|
|
||||||
sort_order=0,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if articles_page:
|
|
||||||
nav_items.append(
|
|
||||||
NavigationMenuItem(
|
|
||||||
settings=settings,
|
|
||||||
link_page=articles_page,
|
|
||||||
link_title="Articles",
|
|
||||||
show_in_header=True,
|
|
||||||
show_in_footer=True,
|
|
||||||
sort_order=1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if about_page:
|
|
||||||
nav_items.append(
|
|
||||||
NavigationMenuItem(
|
|
||||||
settings=settings,
|
|
||||||
link_page=about_page,
|
|
||||||
link_title="About",
|
|
||||||
show_in_header=True,
|
|
||||||
show_in_footer=True,
|
|
||||||
sort_order=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
NavigationMenuItem.objects.bulk_create(nav_items)
|
|
||||||
|
|
||||||
# Social links
|
|
||||||
if not SocialMediaLink.objects.filter(settings=settings).exists():
|
|
||||||
SocialMediaLink.objects.bulk_create(
|
|
||||||
[
|
|
||||||
SocialMediaLink(
|
|
||||||
settings=settings,
|
|
||||||
platform="twitter",
|
|
||||||
url="https://twitter.com/nohypeai",
|
|
||||||
label="Twitter (X)",
|
|
||||||
sort_order=0,
|
|
||||||
),
|
|
||||||
SocialMediaLink(
|
|
||||||
settings=settings,
|
|
||||||
platform="rss",
|
|
||||||
url="/feed/",
|
|
||||||
label="RSS Feed",
|
|
||||||
sort_order=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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,24 +1,10 @@
|
|||||||
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(ClusterableModel, BaseSiteSetting):
|
class SiteSettings(BaseSiteSetting):
|
||||||
default_og_image = models.ForeignKey(
|
default_og_image = models.ForeignKey(
|
||||||
"wagtailimages.Image",
|
"wagtailimages.Image",
|
||||||
null=True,
|
null=True,
|
||||||
@@ -33,140 +19,3 @@ class SiteSettings(ClusterableModel, 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,7 +5,6 @@ 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()
|
||||||
@@ -21,31 +20,6 @@ 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):
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from wagtail.models import Site
|
|
||||||
|
|
||||||
from apps.blog.models import AboutPage, ArticleIndexPage
|
|
||||||
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def site_with_nav(home_page):
|
|
||||||
"""Create SiteSettings with nav items and social links for testing."""
|
|
||||||
site = Site.objects.get(is_default_site=True)
|
|
||||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
|
||||||
|
|
||||||
# Clear any items seeded by the data migration
|
|
||||||
settings.navigation_items.all().delete()
|
|
||||||
settings.social_links.all().delete()
|
|
||||||
|
|
||||||
# Create article index and about page
|
|
||||||
article_index = ArticleIndexPage(title="Articles", slug="articles")
|
|
||||||
home_page.add_child(instance=article_index)
|
|
||||||
article_index.save_revision().publish()
|
|
||||||
|
|
||||||
about = AboutPage(
|
|
||||||
title="About",
|
|
||||||
slug="about",
|
|
||||||
mission_statement="Test mission",
|
|
||||||
body="<p>About page</p>",
|
|
||||||
)
|
|
||||||
home_page.add_child(instance=about)
|
|
||||||
about.save_revision().publish()
|
|
||||||
|
|
||||||
# Create nav items
|
|
||||||
NavigationMenuItem.objects.create(
|
|
||||||
settings=settings,
|
|
||||||
link_page=home_page,
|
|
||||||
link_title="Home",
|
|
||||||
show_in_header=True,
|
|
||||||
show_in_footer=True,
|
|
||||||
sort_order=0,
|
|
||||||
)
|
|
||||||
NavigationMenuItem.objects.create(
|
|
||||||
settings=settings,
|
|
||||||
link_page=article_index,
|
|
||||||
link_title="Articles",
|
|
||||||
show_in_header=True,
|
|
||||||
show_in_footer=True,
|
|
||||||
sort_order=1,
|
|
||||||
)
|
|
||||||
NavigationMenuItem.objects.create(
|
|
||||||
settings=settings,
|
|
||||||
link_page=about,
|
|
||||||
link_title="About",
|
|
||||||
show_in_header=True,
|
|
||||||
show_in_footer=False,
|
|
||||||
sort_order=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Social links
|
|
||||||
SocialMediaLink.objects.create(
|
|
||||||
settings=settings,
|
|
||||||
platform="twitter",
|
|
||||||
url="https://twitter.com/nohypeai",
|
|
||||||
label="Twitter (X)",
|
|
||||||
sort_order=0,
|
|
||||||
)
|
|
||||||
SocialMediaLink.objects.create(
|
|
||||||
settings=settings,
|
|
||||||
platform="rss",
|
|
||||||
url="/feed/",
|
|
||||||
label="RSS Feed",
|
|
||||||
sort_order=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
return settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestNavigationMenuItem:
|
|
||||||
def test_live_page_is_rendered(self, site_with_nav):
|
|
||||||
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
|
||||||
assert len(items) == 3
|
|
||||||
|
|
||||||
def test_unpublished_page_excluded(self, site_with_nav):
|
|
||||||
about_item = site_with_nav.navigation_items.get(link_title="About")
|
|
||||||
about_item.link_page.unpublish()
|
|
||||||
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
|
|
||||||
assert len(items) == 2
|
|
||||||
assert all(i.link_title != "About" for i in items)
|
|
||||||
|
|
||||||
def test_external_url_item(self, site_with_nav):
|
|
||||||
NavigationMenuItem.objects.create(
|
|
||||||
settings=site_with_nav,
|
|
||||||
link_url="https://example.com",
|
|
||||||
link_title="External",
|
|
||||||
sort_order=10,
|
|
||||||
)
|
|
||||||
item = site_with_nav.navigation_items.get(link_title="External")
|
|
||||||
assert item.is_live is True
|
|
||||||
assert item.url == "https://example.com"
|
|
||||||
assert item.title == "External"
|
|
||||||
|
|
||||||
def test_title_falls_back_to_page_title(self, site_with_nav):
|
|
||||||
item = site_with_nav.navigation_items.get(sort_order=0)
|
|
||||||
item.link_title = ""
|
|
||||||
item.save()
|
|
||||||
assert item.title == item.link_page.title
|
|
||||||
|
|
||||||
def test_header_footer_filtering(self, site_with_nav):
|
|
||||||
header_items = site_with_nav.navigation_items.filter(show_in_header=True)
|
|
||||||
footer_items = site_with_nav.navigation_items.filter(show_in_footer=True)
|
|
||||||
assert header_items.count() == 3
|
|
||||||
assert footer_items.count() == 2 # About excluded from footer
|
|
||||||
|
|
||||||
def test_sort_order_respected(self, site_with_nav):
|
|
||||||
items = list(site_with_nav.navigation_items.all().order_by("sort_order"))
|
|
||||||
assert [i.link_title for i in items] == ["Home", "Articles", "About"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestSocialMediaLink:
|
|
||||||
def test_display_label_from_field(self, site_with_nav):
|
|
||||||
link = site_with_nav.social_links.get(platform="twitter")
|
|
||||||
assert link.display_label == "Twitter (X)"
|
|
||||||
|
|
||||||
def test_display_label_fallback(self, site_with_nav):
|
|
||||||
link = site_with_nav.social_links.get(platform="twitter")
|
|
||||||
link.label = ""
|
|
||||||
assert link.display_label == "Twitter / X"
|
|
||||||
|
|
||||||
def test_icon_template_path(self, site_with_nav):
|
|
||||||
link = site_with_nav.social_links.get(platform="rss")
|
|
||||||
assert link.icon_template == "components/icons/rss.html"
|
|
||||||
|
|
||||||
def test_ordering(self, site_with_nav):
|
|
||||||
links = list(site_with_nav.social_links.all().order_by("sort_order"))
|
|
||||||
assert [link.platform for link in links] == ["twitter", "rss"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestSiteSettingsDefaults:
|
|
||||||
def test_default_site_name(self, home_page):
|
|
||||||
site = Site.objects.get(is_default_site=True)
|
|
||||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
|
||||||
assert settings.site_name == "NO HYPE AI"
|
|
||||||
|
|
||||||
def test_default_copyright(self, home_page):
|
|
||||||
site = Site.objects.get(is_default_site=True)
|
|
||||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
|
||||||
assert settings.copyright_text == "No Hype AI. All rights reserved."
|
|
||||||
|
|
||||||
def test_default_tagline(self, home_page):
|
|
||||||
site = Site.objects.get(is_default_site=True)
|
|
||||||
settings, _ = SiteSettings.objects.get_or_create(site=site)
|
|
||||||
assert settings.tagline == "Honest AI tool reviews for developers."
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestNavRendering:
|
|
||||||
def test_header_shows_nav_items(self, client, site_with_nav):
|
|
||||||
resp = client.get("/")
|
|
||||||
content = resp.content.decode()
|
|
||||||
assert "Home" in content
|
|
||||||
assert "Articles" in content
|
|
||||||
assert "About" in content
|
|
||||||
|
|
||||||
def test_unpublished_page_not_in_header(self, client, site_with_nav):
|
|
||||||
about_item = site_with_nav.navigation_items.get(link_title="About")
|
|
||||||
about_item.link_page.unpublish()
|
|
||||||
resp = client.get("/")
|
|
||||||
content = resp.content.decode()
|
|
||||||
# About should not appear as a nav link (but might appear elsewhere on page)
|
|
||||||
assert 'href="/about/"' not in content
|
|
||||||
|
|
||||||
def test_footer_shows_nav_items(self, client, site_with_nav):
|
|
||||||
resp = client.get("/")
|
|
||||||
content = resp.content.decode()
|
|
||||||
# Footer should have social links
|
|
||||||
assert "Twitter (X)" in content
|
|
||||||
assert "RSS Feed" in content
|
|
||||||
|
|
||||||
def test_footer_shows_branding(self, client, site_with_nav):
|
|
||||||
site_with_nav.site_name = "TEST SITE"
|
|
||||||
site_with_nav.save()
|
|
||||||
resp = client.get("/")
|
|
||||||
content = resp.content.decode()
|
|
||||||
assert "TEST SITE" in content
|
|
||||||
|
|
||||||
def test_footer_shows_copyright(self, client, site_with_nav):
|
|
||||||
resp = client.get("/")
|
|
||||||
content = resp.content.decode()
|
|
||||||
assert "No Hype AI. All rights reserved." in content
|
|
||||||
@@ -142,13 +142,6 @@ X_CONTENT_TYPE_OPTIONS = "nosniff"
|
|||||||
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
|
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()]
|
||||||
|
|
||||||
STORAGES = {
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
||||||
},
|
|
||||||
"staticfiles": {
|
|
||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
TAILWIND_APP_NAME = "theme"
|
TAILWIND_APP_NAME = "theme"
|
||||||
|
|||||||
@@ -4,20 +4,12 @@ DEBUG = True
|
|||||||
|
|
||||||
INTERNAL_IPS = ["127.0.0.1"]
|
INTERNAL_IPS = ["127.0.0.1"]
|
||||||
|
|
||||||
# Drop WhiteNoise in dev — it serves from STATIC_ROOT which is empty without
|
# In dev, drop WhiteNoise from middleware and use plain static file storage.
|
||||||
# collectstatic, so it 404s every asset. Django's runserver serves static and
|
# WhiteNoise serves from STATIC_ROOT which is empty without collectstatic,
|
||||||
# media files natively when DEBUG=True (via django.contrib.staticfiles + the
|
# so it intercepts every /static/ request and returns nothing.
|
||||||
# media URL pattern in urls.py).
|
# Django's runserver handles static files natively when DEBUG=True.
|
||||||
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
|
||||||
STORAGES = {
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
"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
|
||||||
@@ -26,5 +18,3 @@ 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
|
|
||||||
|
|||||||
@@ -2,16 +2,8 @@ from .base import * # noqa
|
|||||||
|
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
|
|
||||||
# Behind Caddy: trust the forwarded proto header so Django knows it's HTTPS.
|
|
||||||
# SECURE_SSL_REDIRECT is intentionally off — Caddy handles HTTPS redirects
|
|
||||||
# before the request reaches Django; enabling it here causes redirect loops.
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
SECURE_SSL_REDIRECT = False
|
SECURE_SSL_REDIRECT = True
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
|
||||||
"https://nohypeai.net",
|
|
||||||
"https://www.nohypeai.net",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
from django.conf import settings
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
@@ -23,6 +21,3 @@ urlpatterns = [
|
|||||||
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
path("admin/", RedirectView.as_view(url="/cms/", permanent=False)),
|
||||||
path("", include(wagtail_urls)),
|
path("", include(wagtail_urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
nohypeai.net, www.nohypeai.net {
|
|
||||||
encode gzip zstd
|
|
||||||
|
|
||||||
header {
|
|
||||||
X-Content-Type-Options nosniff
|
|
||||||
X-Frame-Options DENY
|
|
||||||
Referrer-Policy strict-origin-when-cross-origin
|
|
||||||
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
|
||||||
X-Forwarded-Proto https
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_path /static/* {
|
|
||||||
root * /srv/sum/nohype/static
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_path /media/* {
|
|
||||||
root * /srv/sum/nohype/media
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
reverse_proxy localhost:8001
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Deploy script for No Hype AI — runs on lintel-prod-01 as deploy user.
|
|
||||||
# Called by CI after a successful push to main.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SITE_DIR=/srv/sum/nohype
|
|
||||||
APP_DIR=${SITE_DIR}/app
|
|
||||||
|
|
||||||
cd "${SITE_DIR}"
|
|
||||||
|
|
||||||
echo "==> Pulling latest code"
|
|
||||||
git -C "${APP_DIR}" pull origin main
|
|
||||||
|
|
||||||
echo "==> Updating compose file"
|
|
||||||
cp "${APP_DIR}/docker-compose.prod.yml" "${SITE_DIR}/docker-compose.prod.yml"
|
|
||||||
|
|
||||||
echo "==> Ensuring static/media directories exist"
|
|
||||||
mkdir -p "${SITE_DIR}/static" "${SITE_DIR}/media"
|
|
||||||
|
|
||||||
echo "==> Rebuilding and recreating web container"
|
|
||||||
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" up -d --no-deps --build --force-recreate web
|
|
||||||
|
|
||||||
echo "==> Waiting for health check"
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -fsS -H "Host: nohypeai.net" http://localhost:8001/ >/dev/null 2>&1; then
|
|
||||||
echo "==> Site is up"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
echo "ERROR: site did not come up after 90s" >&2
|
|
||||||
docker compose -f "${SITE_DIR}/docker-compose.prod.yml" logs --tail=50 web
|
|
||||||
exit 1
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
python manage.py tailwind install --no-input
|
|
||||||
python manage.py tailwind build
|
|
||||||
python manage.py migrate --noinput
|
|
||||||
python manage.py collectstatic --noinput
|
|
||||||
|
|
||||||
# Set Wagtail site hostname from first entry in ALLOWED_HOSTS
|
|
||||||
python manage.py shell -c "
|
|
||||||
from wagtail.models import Site
|
|
||||||
import os
|
|
||||||
hostname = os.environ.get('ALLOWED_HOSTS', 'localhost').split(',')[0].strip()
|
|
||||||
Site.objects.update(hostname=hostname, port=443, site_name='No Hype AI')
|
|
||||||
"
|
|
||||||
|
|
||||||
exec gunicorn config.wsgi:application \
|
|
||||||
--workers 3 \
|
|
||||||
--bind 0.0.0.0:8000 \
|
|
||||||
--access-logfile - \
|
|
||||||
--error-logfile - \
|
|
||||||
--capture-output
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=No Hype AI (Docker Compose)
|
|
||||||
Requires=docker.service
|
|
||||||
After=docker.service network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=deploy
|
|
||||||
Group=www-data
|
|
||||||
WorkingDirectory=/srv/sum/nohype
|
|
||||||
|
|
||||||
ExecStartPre=docker compose -f docker-compose.prod.yml pull --ignore-pull-failures
|
|
||||||
ExecStart=docker compose -f docker-compose.prod.yml up --build
|
|
||||||
ExecStop=docker compose -f docker-compose.prod.yml down
|
|
||||||
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
TimeoutStartSec=300
|
|
||||||
TimeoutStopSec=30
|
|
||||||
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=sum-nohype
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
services:
|
|
||||||
web:
|
|
||||||
build: app
|
|
||||||
working_dir: /app
|
|
||||||
command: /app/deploy/entrypoint.prod.sh
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
DJANGO_SETTINGS_MODULE: config.settings.production
|
|
||||||
volumes:
|
|
||||||
- /srv/sum/nohype/static:/app/staticfiles
|
|
||||||
- /srv/sum/nohype/media:/app/media
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:8001:8000"
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: nohype
|
|
||||||
POSTGRES_USER: nohype
|
|
||||||
volumes:
|
|
||||||
- nohype_pg:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U nohype -d nohype"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
start_period: 10s
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
nohype_pg:
|
|
||||||
@@ -3,9 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
command: >
|
command: >
|
||||||
sh -c "python manage.py tailwind install --no-input &&
|
sh -c "python manage.py migrate --noinput &&
|
||||||
python manage.py tailwind build &&
|
|
||||||
python manage.py migrate --noinput &&
|
|
||||||
python manage.py seed_e2e_content &&
|
python manage.py seed_e2e_content &&
|
||||||
python manage.py runserver 0.0.0.0:8000"
|
python manage.py runserver 0.0.0.0:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -12,87 +12,39 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None:
|
@pytest.mark.e2e
|
||||||
"""Fill and submit the main (non-reply) comment form."""
|
def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None:
|
||||||
form = page.locator("form[data-comment-form]")
|
_go_to_article(page, base_url)
|
||||||
form.locator('input[name="author_name"]').fill(name)
|
|
||||||
form.locator('input[name="author_email"]').fill(email)
|
# Fill the main comment form (not a reply form)
|
||||||
form.locator('textarea[name="body"]').fill(body)
|
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
||||||
|
form.locator('input[name="author_name"]').fill("E2E Tester")
|
||||||
|
form.locator('input[name="author_email"]').fill("e2e@example.com")
|
||||||
|
form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.")
|
||||||
form.get_by_role("button", name="Post comment").click()
|
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)
|
||||||
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
|
assert "commented=1" in page.url
|
||||||
|
|
||||||
|
|
||||||
@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")
|
|
||||||
|
|
||||||
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
|
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment"))
|
||||||
assert "commented=1" not in page.url
|
form.locator('input[name="author_name"]').fill("E2E Tester")
|
||||||
|
|
||||||
|
|
||||||
@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("Comment without a name.")
|
form.locator('textarea[name="body"]').fill(" ") # whitespace-only body
|
||||||
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."""
|
||||||
@@ -100,7 +52,8 @@ def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> Non
|
|||||||
assert response is not None and response.status == 200, (
|
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,11 +37,12 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
def test_nav_subscribe_cta_present(page: Page, base_url: str) -> None:
|
def test_newsletter_form_in_nav(page: Page, base_url: str) -> None:
|
||||||
page.goto(f"{base_url}/", wait_until="networkidle")
|
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")
|
||||||
# Nav has a Subscribe CTA link (not a form — wireframe spec)
|
expect(nav.locator('input[type="email"]')).to_be_visible()
|
||||||
expect(nav.get_by_role("link", name="Subscribe")).to_be_visible()
|
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ from playwright.sync_api import Page, expect
|
|||||||
|
|
||||||
|
|
||||||
def _nav_newsletter_form(page: Page):
|
def _nav_newsletter_form(page: Page):
|
||||||
"""Return the newsletter form in the home page sidebar aside."""
|
return page.locator("nav").locator("form[data-newsletter-form]")
|
||||||
return page.locator("aside").locator("form[data-newsletter-form]").first
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
@pytest.mark.e2e
|
||||||
@@ -29,7 +28,7 @@ def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None:
|
|||||||
form = _nav_newsletter_form(page)
|
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('aside form[data-newsletter-form]').setAttribute('novalidate', '')")
|
page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')")
|
||||||
form.locator('input[type="email"]').fill("not-an-email")
|
form.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~=12.1
|
Pillow~=11.0.0
|
||||||
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
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<rect width="32" height="32" fill="#09090b"/>
|
|
||||||
<text x="16" y="24" text-anchor="middle" font-family="'Space Grotesk',sans-serif" font-weight="700" font-size="22" fill="#fafafa">/</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 257 B |
@@ -6,10 +6,6 @@
|
|||||||
<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.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700;900&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="{% static 'css/styles.css' %}" />
|
<link rel="stylesheet" href="{% static 'css/styles.css' %}" />
|
||||||
<script nonce="{{ request.csp_nonce|default:'' }}">
|
<script nonce="{{ request.csp_nonce|default:'' }}">
|
||||||
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
|
(function(){try{if(localStorage.getItem('theme')==='light'){document.documentElement.classList.remove('dark');}}catch(e){}})();
|
||||||
@@ -19,18 +15,17 @@
|
|||||||
<script src="{% static 'js/prism.js' %}" defer></script>
|
<script src="{% static 'js/prism.js' %}" defer></script>
|
||||||
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
<script src="{% static 'js/newsletter.js' %}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative">
|
<body>
|
||||||
<div class="fixed inset-0 bg-grid-pattern pointer-events-none z-[-1]"></div>
|
|
||||||
{% include 'components/nav.html' %}
|
{% include 'components/nav.html' %}
|
||||||
{% include 'components/cookie_banner.html' %}
|
{% include 'components/cookie_banner.html' %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<section aria-label="Messages" class="max-w-7xl mx-auto px-6 py-2">
|
<section aria-label="Messages">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
<p class="font-mono text-sm py-2 px-4 bg-brand-cyan/10 text-brand-cyan border border-brand-cyan/20 mb-2">{{ message }}</p>
|
<p>{{ message }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main>
|
<main>{% block content %}{% endblock %}</main>
|
||||||
{% include 'components/footer.html' %}
|
{% include 'components/footer.html' %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,40 +1,14 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load wagtailimages_tags wagtailcore_tags %}
|
{% load wagtailimages_tags wagtailcore_tags %}
|
||||||
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
<!-- Page Header -->
|
<p>{{ page.mission_statement }}</p>
|
||||||
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
{{ page.body|richtext }}
|
||||||
<h1 class="font-display font-black text-4xl md:text-6xl mb-4">{{ page.title }}</h1>
|
{% if page.featured_author %}
|
||||||
{% if page.mission_statement %}
|
<h2>{{ page.featured_author.name }}</h2>
|
||||||
<p class="text-xl md:text-2xl text-zinc-600 dark:text-zinc-400 font-medium max-w-2xl">{{ page.mission_statement }}</p>
|
<p>{{ page.featured_author.bio }}</p>
|
||||||
|
{% if page.featured_author.avatar %}
|
||||||
|
{% image page.featured_author.avatar fill-200x200 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% endif %}
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
|
||||||
<div class="lg:col-span-8 prose prose-lg dark:prose-invert max-w-none
|
|
||||||
prose-headings:font-display prose-headings:font-bold
|
|
||||||
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline">
|
|
||||||
{{ page.body|richtext }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if page.featured_author %}
|
|
||||||
<aside class="lg:col-span-4">
|
|
||||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 relative overflow-hidden">
|
|
||||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-brand-cyan to-brand-pink"></div>
|
|
||||||
{% if page.featured_author.avatar %}
|
|
||||||
<div class="w-20 h-20 mb-4 overflow-hidden border border-zinc-200 dark:border-zinc-800">
|
|
||||||
{% image page.featured_author.avatar fill-80x80 class="w-full h-full object-cover" %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="w-20 h-20 mb-4 bg-gradient-to-tr from-brand-cyan to-brand-pink"></div>
|
|
||||||
{% endif %}
|
|
||||||
<h2 class="font-display font-bold text-xl mb-1">{{ page.featured_author.name }}</h2>
|
|
||||||
{% if page.featured_author.role %}<p class="font-mono text-xs text-zinc-500 mb-3">{{ page.featured_author.role }}</p>{% endif %}
|
|
||||||
{% if page.featured_author.bio %}<p class="text-sm text-zinc-600 dark:text-zinc-400">{{ page.featured_author.bio }}</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -10,39 +10,26 @@
|
|||||||
<meta property="og:url" content="{{ canonical }}" />
|
<meta property="og:url" content="{{ canonical }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
<!-- Page Header -->
|
<section>
|
||||||
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
<h2>Filter by tag</h2>
|
||||||
<h1 class="font-display font-black text-4xl md:text-6xl mb-6">{{ page.title }}</h1>
|
<a href="/articles/" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
||||||
|
{% for tag in available_tags %}
|
||||||
<!-- Tag Filters -->
|
<a href="/articles/?tag={{ tag.slug }}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<a href="/articles/" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if not active_tag %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if not active_tag %}aria-current="page"{% endif %}>All</a>
|
|
||||||
{% for tag in available_tags %}
|
|
||||||
<a href="/articles/?tag={{ tag.slug }}" class="px-4 py-2 font-mono text-sm font-bold border transition-colors {% if active_tag == tag.slug %}bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark border-transparent{% else %}bg-transparent text-zinc-600 dark:text-zinc-400 border-zinc-300 dark:border-zinc-700 hover:text-brand-dark dark:hover:text-brand-light hover:border-brand-dark dark:hover:border-brand-light{% endif %}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Article List -->
|
|
||||||
<div class="space-y-8">
|
|
||||||
{% for article in articles %}
|
|
||||||
{% include 'components/article_card.html' with article=article %}
|
|
||||||
{% empty %}
|
|
||||||
<p class="font-mono text-zinc-500 py-12 text-center">No articles found.</p>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</section>
|
||||||
|
{% for article in articles %}
|
||||||
<!-- Pagination -->
|
{% include 'components/article_card.html' with article=article %}
|
||||||
{% if articles.has_previous or articles.has_next %}
|
{% empty %}
|
||||||
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
|
<p>No articles found.</p>
|
||||||
|
{% endfor %}
|
||||||
|
<nav aria-label="Pagination">
|
||||||
{% if articles.has_previous %}
|
{% if articles.has_previous %}
|
||||||
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">← Previous</a>
|
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-zinc-500">Page {{ articles.number }} of {{ paginator.num_pages }}</span>
|
<span>Page {{ articles.number }} of {{ paginator.num_pages }}</span>
|
||||||
{% if articles.has_next %}
|
{% if articles.has_next %}
|
||||||
<a href="?page={{ articles.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}" class="px-6 py-3 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Next →</a>
|
<a href="?page={{ articles.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Next</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load wagtailcore_tags wagtailimages_tags seo_tags core_tags %}
|
{% load wagtailcore_tags wagtailimages_tags seo_tags %}
|
||||||
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
|
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
|
||||||
{% block head_meta %}
|
{% block head_meta %}
|
||||||
{% canonical_url page as canonical %}
|
{% canonical_url page as canonical %}
|
||||||
@@ -17,209 +17,75 @@
|
|||||||
{% if og_image %}<meta name="twitter:image" content="{{ og_image }}" />{% endif %}
|
{% if og_image %}<meta name="twitter:image" content="{{ og_image }}" />{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<article>
|
||||||
<!-- Breadcrumb -->
|
<h1>{{ page.title }}</h1>
|
||||||
<div class="mb-8 font-mono text-sm text-zinc-500">
|
<p>{{ page.read_time_mins }} min read</p>
|
||||||
<a href="/" class="hover:text-brand-cyan transition-colors">Home</a> /
|
{% if page.hero_image %}
|
||||||
<a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a> /
|
{% image page.hero_image fill-1200x630 %}
|
||||||
<span class="text-brand-dark dark:text-brand-light">{{ page.title|truncatechars:40 }}</span>
|
{% endif %}
|
||||||
</div>
|
{{ page.body }}
|
||||||
|
{% article_json_ld page %}
|
||||||
<!-- Article Header -->
|
</article>
|
||||||
<header class="mb-12 border-b border-zinc-200 dark:border-zinc-800 pb-12">
|
<section aria-label="Share this article">
|
||||||
<div class="flex gap-3 mb-6 items-center flex-wrap">
|
<h2>Share</h2>
|
||||||
{% for tag in page.tags.all %}
|
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer">Share on X</a>
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }} border border-current/20">{{ tag.name }}</span>
|
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a>
|
||||||
{% endfor %}
|
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}">Copy link</button>
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ page.first_published_at|date:"M j, Y" }}</span>
|
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ page.read_time_mins }} min read</span>
|
|
||||||
</div>
|
|
||||||
<h1 class="font-display font-black text-4xl md:text-6xl lg:text-7xl leading-tight mb-8">{{ page.title }}</h1>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-12 h-12 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
|
|
||||||
<div>
|
|
||||||
<div class="font-bold font-display text-lg">{{ page.author.name }}</div>
|
|
||||||
{% if page.author.role %}<div class="font-mono text-xs text-zinc-500">{{ page.author.role }}</div>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{% if page.hero_image %}
|
|
||||||
<div class="mb-12 border border-zinc-200 dark:border-zinc-800 overflow-hidden">
|
|
||||||
{% image page.hero_image width-1200 class="w-full h-auto" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Main Content Layout -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
|
||||||
|
|
||||||
<!-- Article Body -->
|
|
||||||
<article class="lg:col-span-8 prose prose-lg dark:prose-invert max-w-none
|
|
||||||
prose-headings:font-display prose-headings:font-bold
|
|
||||||
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline
|
|
||||||
prose-img:border prose-img:border-zinc-200 dark:prose-img:border-zinc-800
|
|
||||||
prose-blockquote:border-l-brand-pink prose-blockquote:bg-brand-pink/5 prose-blockquote:py-2 prose-blockquote:not-italic
|
|
||||||
prose-code:font-mono prose-code:text-brand-cyan prose-code:bg-zinc-100 dark:prose-code:bg-zinc-900 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none">
|
|
||||||
{{ page.body }}
|
|
||||||
{% article_json_ld page %}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="lg:col-span-4 space-y-8">
|
|
||||||
<div class="sticky top-28">
|
|
||||||
|
|
||||||
<!-- Share -->
|
|
||||||
<section aria-label="Share this article" class="mb-8">
|
|
||||||
<h3 class="font-display font-bold text-lg mb-4 uppercase tracking-widest text-zinc-500 text-sm">Share Article</h3>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<a href="https://x.com/intent/post?url={{ request.build_absolute_uri|urlencode }}&text={{ page.title|urlencode }}" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-brand-cyan transition-colors hover:text-brand-cyan" aria-label="Share on X">
|
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.748l7.73-8.835L1.254 2.25H8.08l4.259 5.63L18.244 2.25zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
|
|
||||||
</a>
|
|
||||||
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer"
|
|
||||||
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-blue-600 transition-colors hover:text-blue-600" aria-label="Share on LinkedIn">
|
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
|
||||||
</a>
|
|
||||||
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}"
|
|
||||||
class="w-10 h-10 flex items-center justify-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:border-brand-pink transition-colors hover:text-brand-pink" aria-label="Copy link">
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Newsletter -->
|
|
||||||
<div class="bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark p-6 border border-transparent dark:border-zinc-700 shadow-solid-dark dark:shadow-solid-light">
|
|
||||||
<h3 class="font-display font-bold text-xl mb-2">Subscribe for Updates</h3>
|
|
||||||
<p class="text-sm opacity-80 mb-4">Get our latest articles and coding benchmarks delivered to your inbox every week.</p>
|
|
||||||
{% include 'components/newsletter_form.html' with source='article' label='Subscribe' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Related Articles -->
|
|
||||||
{% if related_articles %}
|
|
||||||
<section class="mt-16 md:mt-24 pt-12 border-t border-zinc-200 dark:border-zinc-800">
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
|
||||||
<h3 class="font-display font-bold text-3xl">Related Articles</h3>
|
|
||||||
<a href="/articles/" class="font-mono text-sm text-brand-cyan hover:underline">View All</a>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
|
||||||
{% for article in related_articles %}
|
|
||||||
<article class="group flex flex-col h-full bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 hover:-translate-y-2 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
|
|
||||||
<a href="{{ article.url }}" class="h-48 overflow-hidden relative bg-zinc-900 flex items-center justify-center border-b border-zinc-200 dark:border-zinc-800 shrink-0 block">
|
|
||||||
{% if article.hero_image %}
|
|
||||||
{% image article.hero_image fill-400x300 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-16 h-16 text-brand-cyan opacity-40 group-hover:opacity-80 transition-all duration-500 group-hover:scale-110" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="p-6 flex flex-col flex-grow">
|
|
||||||
<div class="flex gap-2 mb-3 flex-wrap">
|
|
||||||
{% for tag in article.tags.all %}
|
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<a href="{{ article.url }}">
|
|
||||||
<h4 class="font-display font-bold text-xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
|
|
||||||
</a>
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6 line-clamp-2">{{ article.summary }}</p>
|
|
||||||
<div class="mt-auto pt-4 border-t border-zinc-100 dark:border-zinc-800 flex items-center gap-2 text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
|
|
||||||
Read Article
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
<section>
|
||||||
|
<h2>Related</h2>
|
||||||
<!-- Comments -->
|
{% for article in related_articles %}
|
||||||
|
<a href="{{ article.url }}">{{ article.title }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
<aside>
|
||||||
|
<h2>Newsletter</h2>
|
||||||
|
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %}
|
||||||
|
</aside>
|
||||||
{% if page.comments_enabled %}
|
{% if page.comments_enabled %}
|
||||||
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
|
<section>
|
||||||
<h2 class="font-display font-bold text-3xl mb-8">Comments</h2>
|
<h2>Comments</h2>
|
||||||
|
{% for comment in approved_comments %}
|
||||||
{% if approved_comments %}
|
<article id="comment-{{ comment.id }}">
|
||||||
<div class="space-y-8 mb-12">
|
<p><strong>{{ comment.author_name }}</strong></p>
|
||||||
{% for comment in approved_comments %}
|
<p>{{ comment.body }}</p>
|
||||||
<article id="comment-{{ comment.id }}" class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
|
||||||
<div class="flex items-center gap-3 mb-3">
|
|
||||||
<div class="w-8 h-8 bg-gradient-to-tr from-brand-cyan to-brand-pink shrink-0"></div>
|
|
||||||
<div>
|
|
||||||
<div class="font-display font-bold text-sm">{{ comment.author_name }}</div>
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ comment.created_at|date:"M j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ comment.body }}</p>
|
|
||||||
{% for reply in comment.replies.all %}
|
{% for reply in comment.replies.all %}
|
||||||
<article id="comment-{{ reply.id }}" class="mt-6 ml-8 bg-zinc-50 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4">
|
<article id="comment-{{ reply.id }}">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<p><strong>{{ reply.author_name }}</strong></p>
|
||||||
<div class="w-6 h-6 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
|
<p>{{ reply.body }}</p>
|
||||||
<div>
|
</article>
|
||||||
<div class="font-display font-bold text-sm">{{ reply.author_name }}</div>
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ reply.created_at|date:"M j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ reply.body }}</p>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
<form method="post" action="{% url 'comment_post' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
<input type="hidden" name="parent_id" value="{{ comment.id }}" />
|
||||||
<div class="flex gap-3 mb-3">
|
<input type="text" name="author_name" required />
|
||||||
<input type="text" name="author_name" required placeholder="Your name"
|
<input type="email" name="author_email" required />
|
||||||
class="flex-1 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" />
|
<textarea name="body" required></textarea>
|
||||||
<input type="email" name="author_email" required placeholder="your@email.com"
|
<input type="text" name="honeypot" style="display:none" />
|
||||||
class="flex-1 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" />
|
<button type="submit">Reply</button>
|
||||||
</div>
|
|
||||||
<textarea name="body" required placeholder="Write a reply..." rows="2"
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors mb-3 resize-none"></textarea>
|
|
||||||
<input type="text" name="honeypot" hidden /> <button type="submit" class="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 font-display font-bold text-sm hover:bg-brand-pink hover:text-white transition-colors">Reply</button>
|
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% empty %}
|
||||||
</div>
|
<p>No comments yet.</p>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<p class="font-mono text-sm text-zinc-500 mb-12">No comments yet. Be the first to comment.</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if comment_form and comment_form.errors %}
|
{% if comment_form and comment_form.errors %}
|
||||||
<div aria-label="Comment form errors" class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 font-mono text-sm text-red-600 dark:text-red-400">
|
<div aria-label="Comment form errors">
|
||||||
{{ comment_form.non_field_errors }}
|
{{ comment_form.non_field_errors }}
|
||||||
{% for field in comment_form %}{{ field.errors }}{% endfor %}
|
{% for field in comment_form %}
|
||||||
</div>
|
{{ field.errors }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<form method="post" action="{% url 'comment_post' %}">
|
||||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
|
{% csrf_token %}
|
||||||
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
|
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
||||||
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4">
|
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required />
|
||||||
{% csrf_token %}
|
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required />
|
||||||
<input type="hidden" name="article_id" value="{{ page.id }}" />
|
<textarea name="body" required>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<input type="text" name="honeypot" style="display:none" />
|
||||||
<div>
|
<button type="submit">Post comment</button>
|
||||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Name *</label>
|
</form>
|
||||||
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
|
|
||||||
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" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Email *</label>
|
|
||||||
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required
|
|
||||||
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" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Comment *</label>
|
|
||||||
<textarea name="body" required rows="5"
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors resize-none">{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea>
|
|
||||||
</div>
|
|
||||||
<input type="text" name="honeypot" hidden />
|
|
||||||
<button type="submit" class="px-6 py-3 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all">Post comment</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
<div class="bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-6 my-8 flex items-start gap-4
|
<div class="callout icon-{{ value.icon }}">
|
||||||
{% if value.icon == 'warning' %}border-l-4 border-l-yellow-400{% elif value.icon == 'trophy' %}border-l-4 border-l-brand-pink{% elif value.icon == 'tip' %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-brand-cyan{% endif %}">
|
<h3>{{ value.heading }}</h3>
|
||||||
<div class="shrink-0 mt-0.5">
|
{{ value.body }}
|
||||||
{% if value.icon == 'warning' %}
|
|
||||||
<svg class="w-6 h-6 text-yellow-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
|
|
||||||
{% elif value.icon == 'trophy' %}
|
|
||||||
<svg class="w-6 h-6 text-brand-pink" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 18.75h-9m9 0a3 3 0 0 1 3 3h-15a3 3 0 0 1 3-3m9 0v-3.375c0-.621-.503-1.125-1.125-1.125h-.871M7.5 18.75v-3.375c0-.621.504-1.125 1.125-1.125h.872m5.007 0H9.497m5.007 0a7.454 7.454 0 0 1-.982-3.172M9.497 14.25a7.454 7.454 0 0 0 .981-3.172M5.25 4.236c-.982.143-1.954.317-2.916.52A6.003 6.003 0 0 0 7.73 9.728M5.25 4.236V4.5c0 2.108.966 3.99 2.48 5.228M5.25 4.236V2.721C7.456 2.41 9.71 2.25 12 2.25c2.291 0 4.545.16 6.75.47v1.516M7.73 9.728a6.726 6.726 0 0 0 2.748 1.35m8.272-6.842V4.5c0 2.108-.966 3.99-2.48 5.228m2.48-5.492a46.32 46.32 0 0 1 2.916.52 6.003 6.003 0 0 1-5.395 4.972m0 0a6.726 6.726 0 0 1-2.749 1.35m0 0a6.772 6.772 0 0 1-3.044 0" /></svg>
|
|
||||||
{% elif value.icon == 'tip' %}
|
|
||||||
<svg class="w-6 h-6 text-green-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 0 0 1.5-.189m-1.5.189a6.01 6.01 0 0 1-1.5-.189m3.75 7.478a12.06 12.06 0 0 1-4.5 0m3.75 2.383a14.406 14.406 0 0 1-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 1 0-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" /></svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-6 h-6 text-brand-cyan" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
{% if value.heading %}
|
|
||||||
<h4 class="font-display font-bold text-lg mb-2">{{ value.heading }}</h4>
|
|
||||||
{% endif %}
|
|
||||||
<div class="text-zinc-700 dark:text-zinc-300 text-sm leading-relaxed">{{ value.body }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
{% load wagtailcore_tags %}
|
{% load wagtailcore_tags %}
|
||||||
<div class="my-8 overflow-hidden bg-[#0d1117] border border-zinc-800 shadow-xl">
|
<div class="code-block">
|
||||||
<div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-zinc-800">
|
{% if value.filename %}<div>{{ value.filename }}</div>{% endif %}
|
||||||
<div class="flex gap-2">
|
<pre data-lang="{{ value.language }}"><code class="language-{{ value.language }}">{{ value.raw_code }}</code></pre>
|
||||||
<div class="w-3 h-3 rounded-full bg-red-500"></div>
|
|
||||||
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
|
|
||||||
<div class="w-3 h-3 rounded-full bg-green-500"></div>
|
|
||||||
</div>
|
|
||||||
{% if value.filename %}
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ value.filename }}</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="font-mono text-xs text-zinc-500">{{ value.language }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="w-8"></div>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<pre data-lang="{{ value.language }}" class="p-6 text-sm"><code class="language-{{ value.language }} font-mono text-zinc-300">{{ value.raw_code }}</code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load wagtailimages_tags seo_tags core_tags %}
|
{% load seo_tags %}
|
||||||
{% block title %}No Hype AI{% endblock %}
|
{% block title %}No Hype AI{% endblock %}
|
||||||
{% block head_meta %}
|
{% block head_meta %}
|
||||||
{% canonical_url page as canonical %}
|
{% canonical_url page as canonical %}
|
||||||
@@ -11,146 +11,21 @@
|
|||||||
<meta property="og:url" content="{{ canonical }}" />
|
<meta property="og:url" content="{{ canonical }}" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<section>
|
||||||
<!-- Featured Article -->
|
{% if featured_article %}
|
||||||
{% if featured_article %}
|
<h2>{{ featured_article.title }}</h2>
|
||||||
<section class="mb-12 md:mb-16">
|
<p>{{ featured_article.author.name }}</p>
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<p>{{ featured_article.read_time_mins }} min read</p>
|
||||||
<span class="w-2 h-2 rounded-full bg-brand-pink animate-pulse"></span>
|
{% endif %}
|
||||||
<span class="font-mono text-sm font-bold uppercase tracking-widest text-zinc-500">Featured Article</span>
|
</section>
|
||||||
</div>
|
<section>
|
||||||
<article class="group cursor-pointer grid grid-cols-1 lg:grid-cols-2 gap-8 items-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 md:p-6 hover:border-brand-cyan dark:hover:border-brand-cyan hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all duration-300">
|
{% for article in latest_articles %}
|
||||||
<a href="{{ featured_article.url }}" class="w-full h-64 md:h-[400px] overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 order-2 lg:order-1 border border-zinc-200 dark:border-zinc-800 block">
|
{% include 'components/article_card.html' with article=article %}
|
||||||
{% if featured_article.hero_image %}
|
{% endfor %}
|
||||||
{% image featured_article.hero_image fill-800x600 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-700 group-hover:scale-105" %}
|
</section>
|
||||||
{% else %}
|
<section>
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
{% for article in more_articles %}
|
||||||
<svg class="w-20 h-20 text-brand-cyan opacity-30" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
|
{% include 'components/article_card.html' with article=article %}
|
||||||
</div>
|
{% endfor %}
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="flex flex-col py-2 order-1 lg:order-2">
|
|
||||||
<div class="flex gap-3 mb-4 items-center flex-wrap">
|
|
||||||
{% for tag in featured_article.tags.all %}
|
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ featured_article.read_time_mins }} min read</span>
|
|
||||||
</div>
|
|
||||||
<a href="{{ featured_article.url }}">
|
|
||||||
<h2 class="font-display font-black text-3xl md:text-5xl mb-4 group-hover:text-brand-cyan transition-colors leading-[1.1]">{{ featured_article.title }}</h2>
|
|
||||||
</a>
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400 mb-8 text-lg md:text-xl line-clamp-3">{{ featured_article.summary }}</p>
|
|
||||||
<div class="mt-auto flex items-center justify-between pt-4 border-t border-zinc-200 dark:border-zinc-800">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 bg-gradient-to-tr from-brand-cyan to-brand-pink"></div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm font-bold font-display">{{ featured_article.author.name }}</div>
|
|
||||||
<div class="text-xs font-mono text-zinc-500">{{ featured_article.first_published_at|date:"M j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a href="{{ featured_article.url }}" class="text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors flex items-center gap-1">
|
|
||||||
Read
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" /></svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- 2-Column Editorial Layout -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
|
||||||
|
|
||||||
<!-- Main Feed -->
|
|
||||||
<div class="lg:col-span-8">
|
|
||||||
<div class="flex items-center justify-between mb-8 pb-4 border-b border-zinc-200 dark:border-zinc-800">
|
|
||||||
<h3 class="font-display font-bold text-3xl">Latest Articles</h3>
|
|
||||||
<a href="/articles/" class="font-mono text-sm text-brand-cyan hover:underline flex items-center gap-1">View All</a>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-8">
|
|
||||||
{% for article in latest_articles %}
|
|
||||||
<article class="group flex flex-col md:flex-row gap-6 items-start pb-8 border-b border-zinc-200 dark:border-zinc-800 last:border-0">
|
|
||||||
<a href="{{ article.url }}" class="w-full md:w-48 h-48 md:h-32 shrink-0 overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 flex items-center justify-center block">
|
|
||||||
{% if article.hero_image %}
|
|
||||||
{% image article.hero_image fill-200x130 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-12 h-12 text-brand-cyan opacity-40 group-hover:opacity-80 transition-all duration-500 group-hover:scale-110" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="flex flex-col w-full">
|
|
||||||
<div class="flex gap-3 mb-2 items-center flex-wrap">
|
|
||||||
{% for tag in article.tags.all %}
|
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ article.first_published_at|date:"M j" }}</span>
|
|
||||||
</div>
|
|
||||||
<a href="{{ article.url }}">
|
|
||||||
<h4 class="font-display font-bold text-2xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
|
|
||||||
</a>
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400 text-sm line-clamp-2 mb-3">{{ article.summary }}</p>
|
|
||||||
<a href="{{ article.url }}" class="text-xs font-mono font-bold group-hover:text-brand-cyan transition-colors mt-auto">Read article →</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if more_articles %}
|
|
||||||
<div class="mt-10">
|
|
||||||
<div class="flex items-center justify-between mb-6 pb-4 border-b border-zinc-200 dark:border-zinc-800">
|
|
||||||
<h3 class="font-display font-bold text-2xl">More Articles</h3>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-6">
|
|
||||||
{% for article in more_articles %}
|
|
||||||
{% include 'components/article_card.html' with article=article %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="lg:col-span-4 space-y-8">
|
|
||||||
|
|
||||||
<!-- Newsletter Widget -->
|
|
||||||
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6 relative overflow-hidden">
|
|
||||||
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-brand-cyan to-brand-pink"></div>
|
|
||||||
<h4 class="font-display font-bold text-xl mb-2">Weekly Newsletter</h4>
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400 text-sm mb-4">Get our latest articles and coding benchmarks delivered to your inbox every week.</p>
|
|
||||||
{% include 'components/newsletter_form.html' with source='sidebar' label='Subscribe' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Popular Articles -->
|
|
||||||
{% if latest_articles %}
|
|
||||||
<div>
|
|
||||||
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Popular Articles</h4>
|
|
||||||
<ul class="space-y-4">
|
|
||||||
{% for article in latest_articles %}
|
|
||||||
<li class="group">
|
|
||||||
<a href="{{ article.url }}" class="flex gap-4 items-start">
|
|
||||||
<span class="font-display font-black text-2xl text-zinc-300 dark:text-zinc-800 group-hover:text-brand-{% cycle 'cyan' 'pink' 'cyan' 'pink' 'cyan' %} transition-colors">0{{ forloop.counter }}</span>
|
|
||||||
<div>
|
|
||||||
<h5 class="font-display font-bold text-sm leading-tight group-hover:text-brand-cyan transition-colors">{{ article.title }}</h5>
|
|
||||||
<div class="text-xs font-mono text-zinc-500 mt-1">{{ article.read_time_mins }} min read</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if available_tags %}
|
|
||||||
<div>
|
|
||||||
<h4 class="font-display font-bold mb-4 uppercase tracking-widest text-zinc-500 text-sm">Explore Topics</h4>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{% for tag in available_tags %}
|
|
||||||
<a href="/articles/?tag={{ tag.slug }}" class="px-3 py-1.5 border border-zinc-200 dark:border-zinc-800 text-sm font-mono hover:border-brand-cyan hover:text-brand-cyan transition-colors">#{{ tag.name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,31 +1,8 @@
|
|||||||
{% load core_tags wagtailimages_tags %}
|
{% load core_tags %}
|
||||||
<article class="group flex flex-col md:flex-row gap-8 items-center bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 hover:border-brand-cyan dark:hover:border-brand-cyan transition-colors">
|
<article>
|
||||||
<a href="{{ article.url }}" class="w-full md:w-1/3 h-48 md:h-full min-h-[200px] overflow-hidden relative bg-zinc-100 dark:bg-zinc-900 shrink-0 block">
|
<a href="{{ article.url }}">{{ article.title }}</a>
|
||||||
{% if article.hero_image %}
|
<p>{{ article.summary|truncatewords:20 }}</p>
|
||||||
{% image article.hero_image fill-600x400 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
|
{% for tag in article.tags.all %}
|
||||||
{% else %}
|
<span class="{{ tag|get_tag_css }}">{{ tag.name }}</span>
|
||||||
<div class="w-full h-full min-h-[200px] flex items-center justify-center bg-zinc-900">
|
{% endfor %}
|
||||||
<svg class="w-16 h-16 text-brand-cyan opacity-40 group-hover:opacity-80 transition-opacity" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 1-6.23-.693L5 14.5m14.8.8 1.402 1.402c1 1 .03 2.798-1.442 2.798H4.24c-1.47 0-2.44-1.798-1.442-2.798L4.2 15.3" /></svg>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
<div class="flex flex-col py-2 w-full">
|
|
||||||
<div class="flex gap-3 mb-4 items-center flex-wrap">
|
|
||||||
{% for tag in article.tags.all %}
|
|
||||||
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ article.first_published_at|date:"M j, Y" }}</span>
|
|
||||||
</div>
|
|
||||||
<a href="{{ article.url }}">
|
|
||||||
<h2 class="font-display font-bold text-2xl md:text-3xl mb-3 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h2>
|
|
||||||
</a>
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400 mb-6 max-w-2xl line-clamp-2">{{ article.summary }}</p>
|
|
||||||
<div class="flex items-center justify-between mt-auto">
|
|
||||||
<span class="text-sm font-mono text-zinc-500">{{ article.read_time_mins }} min read</span>
|
|
||||||
<a href="{{ article.url }}" class="flex items-center gap-2 text-sm font-bold font-mono group-hover:text-brand-cyan transition-colors">
|
|
||||||
Read Article
|
|
||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" /></svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,43 +1,27 @@
|
|||||||
{% if request.consent.requires_prompt %}
|
{% if request.consent.requires_prompt %}
|
||||||
<div id="cookie-banner" class="fixed bottom-0 left-0 right-0 z-50 bg-brand-surfaceLight dark:bg-brand-surfaceDark border-t border-zinc-200 dark:border-zinc-800 shadow-lg">
|
<div id="cookie-banner">
|
||||||
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
<form method="post" action="{% url 'consent' %}">
|
||||||
<div class="flex-1">
|
{% csrf_token %}
|
||||||
<p class="font-mono text-sm text-zinc-600 dark:text-zinc-400">
|
<button type="submit" name="accept_all" value="1">Accept all</button>
|
||||||
We use cookies to improve your experience.
|
<button type="submit" name="reject_all" value="1">Reject all</button>
|
||||||
{% if site_settings and site_settings.privacy_policy_page %}
|
</form>
|
||||||
<a href="{{ site_settings.privacy_policy_page.url }}" class="text-brand-cyan hover:underline">Privacy Policy</a>
|
<details>
|
||||||
{% endif %}
|
<summary>Manage preferences</summary>
|
||||||
</p>
|
<form method="post" action="{% url 'consent' %}">
|
||||||
</div>
|
{% csrf_token %}
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<label>
|
||||||
<form method="post" action="{% url 'consent' %}" class="inline">
|
<input type="checkbox" name="analytics" value="1" />
|
||||||
{% csrf_token %}
|
Analytics cookies
|
||||||
<button type="submit" name="reject_all" value="1"
|
</label>
|
||||||
class="px-4 py-2 border border-zinc-300 dark:border-zinc-700 font-mono text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors">Reject all</button>
|
<label>
|
||||||
</form>
|
<input type="checkbox" name="advertising" value="1" />
|
||||||
<form method="post" action="{% url 'consent' %}" class="inline">
|
Advertising cookies
|
||||||
{% csrf_token %}
|
</label>
|
||||||
<button type="submit" name="accept_all" value="1"
|
<button type="submit">Save preferences</button>
|
||||||
class="px-4 py-2 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold text-sm hover:bg-brand-cyan transition-colors">Accept all</button>
|
</form>
|
||||||
</form>
|
</details>
|
||||||
<details class="relative">
|
{% if site_settings and site_settings.privacy_policy_page %}
|
||||||
<summary class="px-4 py-2 border border-zinc-300 dark:border-zinc-700 font-mono text-sm hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors cursor-pointer list-none">Manage</summary>
|
<a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a>
|
||||||
<div class="absolute bottom-full right-0 mb-2 w-72 bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-4 shadow-lg">
|
{% endif %}
|
||||||
<form method="post" action="{% url 'consent' %}" class="space-y-3">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label class="flex items-center gap-3 font-mono text-sm cursor-pointer">
|
|
||||||
<input type="checkbox" name="analytics" value="1" class="accent-brand-cyan" />
|
|
||||||
Analytics cookies
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-3 font-mono text-sm cursor-pointer">
|
|
||||||
<input type="checkbox" name="advertising" value="1" class="accent-brand-pink" />
|
|
||||||
Advertising cookies
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="w-full px-4 py-2 bg-brand-dark text-brand-light dark:bg-brand-light dark:text-brand-dark font-display font-bold text-sm hover:bg-brand-cyan transition-colors">Save preferences</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,42 +1,8 @@
|
|||||||
{% load core_tags %}
|
{% load core_tags %}
|
||||||
{% get_nav_items "footer" as footer_nav_items %}
|
<footer>
|
||||||
{% get_social_links as social_links %}
|
{% get_legal_pages as legal_pages %}
|
||||||
<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">
|
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %}
|
||||||
<div class="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
|
{% for page in legal_pages %}
|
||||||
<div class="md:col-span-2">
|
<a href="{{ page.url }}">{{ page.title }}</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>
|
{% endfor %}
|
||||||
<p class="text-zinc-500 font-mono text-sm max-w-sm mx-auto md:mx-0">
|
|
||||||
{{ site_settings.footer_description|default:"In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers."|linebreaksbr }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Navigation</h4>
|
|
||||||
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
|
||||||
{% for item in footer_nav_items %}
|
|
||||||
<li><a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
{% 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>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 class="font-display font-bold mb-4 uppercase text-sm tracking-widest text-zinc-400">Connect</h4>
|
|
||||||
<ul class="space-y-2 font-mono text-sm text-zinc-500">
|
|
||||||
{% for link in social_links %}
|
|
||||||
<li>
|
|
||||||
<a href="{{ link.url }}" class="hover:text-brand-cyan transition-colors flex items-center justify-center md:justify-start gap-2"{% if link.url != "/feed/" %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
|
||||||
{% include link.icon_template %}
|
|
||||||
{{ link.display_label }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="max-w-7xl mx-auto px-6 mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800 text-center font-mono text-xs text-zinc-500 flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<p>© {% 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>
|
</footer>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Z" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 334 B |
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 266 B |
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 783 B |
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 671 B |
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 330 B |
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 919 B |
@@ -1 +0,0 @@
|
|||||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-8.9a.75.75 0 0 0-.53-1.1H13.5l-1.5 3-1.5-3H4.06a.75.75 0 0 0-.53 1.1l4.72 8.9-4.72 8.9a.75.75 0 0 0 .53 1.1H10.5l1.5-3 1.5 3h6.44a.75.75 0 0 0 .53-1.1l-4.72-8.9Z" /></svg>
|
|
||||||
|
Before Width: | Height: | Size: 374 B |
@@ -1,64 +1,7 @@
|
|||||||
{% load static core_tags %}
|
<nav>
|
||||||
{% get_nav_items "header" as header_items %}
|
<a href="/">Home</a>
|
||||||
<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">
|
<a href="/articles/">Articles</a>
|
||||||
<div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
<a href="/about/">About</a>
|
||||||
<!-- Logo -->
|
<button type="button" data-theme-toggle>Toggle theme</button>
|
||||||
<a href="/" class="group flex items-center gap-2">
|
{% include 'components/newsletter_form.html' with source='nav' label='Get updates' %}
|
||||||
<div class="w-8 h-8 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark flex items-center justify-center font-display font-bold text-xl group-hover:rotate-12 transition-transform">
|
|
||||||
/
|
|
||||||
</div>
|
|
||||||
<span class="font-display font-bold text-2xl tracking-tight">{{ site_settings.site_name|default:"NO HYPE AI" }}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Desktop Links -->
|
|
||||||
<div class="hidden md:flex items-center gap-8 font-medium">
|
|
||||||
{% for item in header_items %}
|
|
||||||
<a href="{{ item.url }}" class="hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
<a href="#newsletter" class="px-5 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold hover:-translate-y-1 hover:shadow-solid-dark dark:hover:shadow-solid-light transition-all border border-transparent dark:border-zinc-700">Subscribe</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theme Toggle + Hamburger -->
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<button type="button" data-theme-toggle class="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors" aria-label="Toggle theme">
|
|
||||||
<svg class="w-5 h-5 hidden dark:block text-yellow-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /></svg>
|
|
||||||
<svg class="w-5 h-5 block dark:hidden text-zinc-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /></svg>
|
|
||||||
</button>
|
|
||||||
<button type="button" data-mobile-menu-toggle class="md:hidden p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded transition-colors" aria-label="Open menu" aria-expanded="false" aria-controls="mobile-menu">
|
|
||||||
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile Menu (outside <nav> to avoid duplicate form[data-newsletter-form] in nav scope) -->
|
|
||||||
<div id="mobile-menu" class="md:hidden hidden sticky top-20 z-40 border-b border-zinc-200 dark:border-zinc-800 bg-brand-light/95 dark:bg-brand-dark/95 backdrop-blur-md">
|
|
||||||
<div class="max-w-7xl mx-auto px-6 py-4 flex flex-col gap-4">
|
|
||||||
{% for item in header_items %}
|
|
||||||
<a href="{{ item.url }}" class="font-medium py-2 hover:text-brand-cyan transition-colors"{% if item.open_in_new_tab %} target="_blank" rel="noopener noreferrer"{% endif %}>{{ item.title }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-2 pt-2 border-t border-zinc-200 dark:border-zinc-800" id="mobile-newsletter">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="source" value="nav-mobile" />
|
|
||||||
<input type="email" name="email" required placeholder="dev@example.com"
|
|
||||||
class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-3 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
|
|
||||||
<input type="text" name="honeypot" hidden />
|
|
||||||
<button type="submit" class="w-full px-4 py-2.5 bg-brand-dark dark:bg-brand-light text-brand-light dark:text-brand-dark font-display font-bold transition-colors">Subscribe</button>
|
|
||||||
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script nonce="{{ request.csp_nonce|default:'' }}">
|
|
||||||
(function(){
|
|
||||||
var btn = document.querySelector('[data-mobile-menu-toggle]');
|
|
||||||
var menu = document.getElementById('mobile-menu');
|
|
||||||
if (btn && menu) {
|
|
||||||
btn.addEventListener('click', function(){
|
|
||||||
var isOpen = !menu.classList.contains('hidden');
|
|
||||||
menu.classList.toggle('hidden');
|
|
||||||
btn.setAttribute('aria-expanded', String(!isOpen));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-3" id="newsletter">
|
<form method="post" action="/newsletter/subscribe/" data-newsletter-form>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<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"
|
<label>
|
||||||
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" />
|
<span>{{ label|default:"Newsletter" }}</span>
|
||||||
<input type="text" name="honeypot" hidden />
|
<input type="email" name="email" required />
|
||||||
<button type="submit"
|
</label>
|
||||||
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">
|
<input type="text" name="honeypot" style="display:none" />
|
||||||
{{ label|default:"Subscribe" }}
|
<button type="submit">Subscribe</button>
|
||||||
</button>
|
<p data-newsletter-message aria-live="polite"></p>
|
||||||
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,18 +1,7 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load wagtailcore_tags %}
|
{% load wagtailcore_tags %}
|
||||||
{% block title %}{{ page.title }} | No Hype AI{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<h1>{{ page.title }}</h1>
|
||||||
<!-- Page Header -->
|
<p>Last updated: {{ page.last_updated|date:'F Y' }}</p>
|
||||||
<div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
|
{{ page.body|richtext }}
|
||||||
<h1 class="font-display font-black text-4xl md:text-5xl mb-3">{{ page.title }}</h1>
|
|
||||||
<p class="font-mono text-sm text-zinc-500">Last updated: {{ page.last_updated|date:'F Y' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="max-w-3xl prose prose-lg dark:prose-invert
|
|
||||||
prose-headings:font-display prose-headings:font-bold
|
|
||||||
prose-a:text-brand-cyan hover:prose-a:text-brand-pink prose-a:transition-colors prose-a:no-underline hover:prose-a:underline">
|
|
||||||
{{ page.body|richtext }}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
28
theme/static_src/package-lock.json
generated
@@ -8,7 +8,6 @@
|
|||||||
"name": "nohype-theme",
|
"name": "nohype-theme",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -94,33 +93,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/typography": {
|
|
||||||
"version": "0.5.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
|
||||||
"integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"postcss-selector-parser": "6.0.10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
|
|
||||||
"version": "6.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
|
||||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cssesc": "^3.0.0",
|
|
||||||
"util-deprecate": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"dev": "tailwindcss -i ./src/input.css -o ../static/css/styles.css --watch"
|
"dev": "tailwindcss -i ./src/input.css -o ../static/css/styles.css --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"tailwindcss": "^3.4.17"
|
"tailwindcss": "^3.4.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,3 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.bg-grid-pattern {
|
|
||||||
background-image: radial-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px);
|
|
||||||
background-size: 24px 24px;
|
|
||||||
}
|
|
||||||
.dark .bg-grid-pattern {
|
|
||||||
background-image: radial-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px);
|
|
||||||
}
|
|
||||||
.text-gradient {
|
|
||||||
background: linear-gradient(135deg, #06b6d4, #ec4899);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
background-color: #ec4899;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
|
||||||
}
|
|
||||||
pre::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
pre::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(156, 163, 175, 0.5);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +1,10 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: 'class',
|
|
||||||
content: [
|
content: [
|
||||||
"../../templates/**/*.html",
|
"../../templates/**/*.html",
|
||||||
"../../apps/**/templates/**/*.html",
|
"../../apps/**/templates/**/*.html"
|
||||||
"../../apps/blog/models.py",
|
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {}
|
||||||
fontFamily: {
|
|
||||||
sans: ['Inter', 'sans-serif'],
|
|
||||||
display: ['Space Grotesk', 'sans-serif'],
|
|
||||||
mono: ['Fira Code', 'monospace'],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
brand: {
|
|
||||||
cyan: '#06b6d4',
|
|
||||||
cyanGlow: 'rgba(6,182,212,0.4)',
|
|
||||||
pink: '#ec4899',
|
|
||||||
dark: '#09090b',
|
|
||||||
light: '#fafafa',
|
|
||||||
surfaceDark: '#18181b',
|
|
||||||
surfaceLight: '#ffffff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
'neon-cyan': '0 0 20px rgba(6, 182, 212, 0.3)',
|
|
||||||
'neon-pink': '0 0 20px rgba(236, 72, 153, 0.3)',
|
|
||||||
'solid-dark': '6px 6px 0px 0px #09090b',
|
|
||||||
'solid-light': '6px 6px 0px 0px #e4e4e7',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plugins: [require('@tailwindcss/typography')],
|
plugins: []
|
||||||
};
|
};
|
||||||
|
|||||||