34 Commits

Author SHA1 Message Date
Mark
1c5ba6cf90 feat: replace hardcoded navigation with CMS-managed models
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m15s
CI / ci (pull_request) Successful in 1m20s
Replace static nav/footer links with Wagtail-managed NavigationMenuItem
and SocialMediaLink orderables on SiteSettings. Unpublished pages are
automatically excluded from rendering, fixing the dead-link problem.

- Extend SiteSettings with site_name, tagline, footer_description,
  copyright_text branding fields
- Add NavigationMenuItem orderable (link_page/link_url, show_in_header,
  show_in_footer, sort_order) with automatic live-page filtering
- Add SocialMediaLink orderable with platform icon templates
- New template tags: get_nav_items, get_social_links
- Update nav.html and footer.html to render from CMS data
- Data migration seeds existing hardcoded values for zero-change deploy
- Update seed_e2e_content command for test/dev environments
- 18 new tests covering models, template tags, and rendered output

Closes #32

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 19:07:35 +00:00
22d596d666 Merge pull request 'ci: re-trigger deploy after fixing PROD_SSH_HOST secret' (#31) from ci/retrigger-deploy into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #31
2026-03-02 18:25:17 +00:00
Mark
987f308e06 ci: re-trigger deploy after fixing PROD_SSH_HOST secret
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m10s
CI / ci (pull_request) Successful in 1m14s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 18:22:50 +00:00
bcc9305a00 Merge pull request 'feat: add SVG favicon matching header logo' (#30) from fix/favicon into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 32s
Reviewed-on: #30
2026-03-02 18:15:30 +00:00
Mark
62ff7f5792 feat: add SVG favicon matching header logo
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m13s
CI / ci (pull_request) Successful in 1m15s
Create a static SVG favicon replicating the nav logo — a forward slash
on a dark (#09090b) square with the brand light (#fafafa) text colour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 18:14:10 +00:00
ad271aa817 Merge pull request 'fix: match tag colours to wireframe neon style' (#29) from fix/tag-neon-colours into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 22s
Reviewed-on: #29
2026-03-02 17:13:04 +00:00
Mark
8a97b6e2a0 fix: match tag colours to wireframe neon style
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m16s
Update TagMetadata CSS classes to use brand colours with translucent
backgrounds matching the wireframe design:
- cyan:    bg-brand-cyan/10 text-brand-cyan
- pink:    bg-brand-pink/10 text-brand-pink
- neutral: bg-zinc-800 text-white (dark: bg-zinc-100 text-black)

Previously used muted Tailwind defaults (bg-cyan-100/text-cyan-900)
which appeared as soft pastels instead of the intended neon look.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 17:10:38 +00:00
43e7068110 Merge pull request 'fix: include blog models in Tailwind content scan for tag colours' (#28) from fix/tag-colour-safelist into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 23s
Reviewed-on: #28
2026-03-02 16:29:55 +00:00
Mark
6bae864c1e fix: include blog models in Tailwind content scan for tag colours
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m16s
Tag colour classes (bg-cyan-100, text-cyan-900, etc.) are generated
dynamically in TagMetadata.get_css_classes() in apps/blog/models.py.
Tailwind's content scanner only covered HTML templates, so these classes
were purged from the CSS build — rendering tags as white-on-white.

Add apps/blog/models.py to the Tailwind content array so the JIT
compiler detects and retains the dynamic colour classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 16:28:06 +00:00
17d30a4073 Merge pull request 'fix: upgrade Pillow to 12.x for native AVIF support' (#27) from fix/pillow-avif-support into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 1m24s
Reviewed-on: #27
2026-03-02 16:15:54 +00:00
Mark
0818f71566 fix: upgrade Pillow to 12.x for native AVIF support
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m38s
CI / ci (pull_request) Successful in 2m9s
Pillow 11.0.x did not include native AVIF support — that was added in
11.1.0. The ~=11.0.0 pin restricted upgrades to 11.0.x, so the
libavif-dev system package installed in the Dockerfile was never used
(pip installs pre-compiled wheels that bundle their own libraries).

Bump to Pillow ~=12.1 which ships native AVIF encoding/decoding in its
PyPI wheels out of the box.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 16:10:26 +00:00
3799d76bed Merge pull request 'fix(docker): add libavif-dev for AVIF image upload support' (#26) from fix/avif-support into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 1m33s
Reviewed-on: #26
2026-03-02 16:00:17 +00:00
Mark
fbe8546b37 fix(docker): add libavif-dev for AVIF image upload support
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 2m53s
CI / ci (pull_request) Successful in 3m27s
Pillow 11 supports AVIF natively but requires libavif to be installed
at the system level. Without it, uploading AVIF images via Wagtail's
image chooser causes an unhandled 500 error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 15:52:57 +00:00
a59d21cfcb Merge pull request 'fix(csp): skip restrictive CSP on Wagtail/Django admin paths' (#25) from fix/csp-wagtail-admin into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 23s
Reviewed-on: #25
2026-03-02 15:36:12 +00:00
Mark
43594777e0 fix(csp): skip restrictive CSP on Wagtail/Django admin paths
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m22s
The SecurityHeadersMiddleware applied a strict style-src policy to all
responses, blocking inline styles that Wagtail admin relies on for
layout. Skip the custom CSP for /cms/ and /django-admin/ paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 15:34:09 +00:00
f7c89be05c Merge pull request 'fix(makefile): point DC at prod compose file' (#24) from fix/makefile-prod-compose into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #24
2026-03-02 15:03:15 +00:00
Mark
2e7949ac23 fix(makefile): point DC at prod compose file
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m23s
The Makefile used bare 'docker compose' which picks up the dev
docker-compose.yml when run from the app directory on prod. Point
it at the absolute path to docker-compose.prod.yml instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 15:01:27 +00:00
f5c2f87820 Merge pull request 'feat: add Makefile for Docker and Django ops' (#23) from feat/makefile into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 54s
Reviewed-on: #23
2026-03-01 14:26:51 +00:00
codex_a
abbc3c3d1d feat: add Makefile for Docker and Django ops
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m13s
CI / ci (pull_request) Successful in 1m25s
Covers:
- Docker: build, up, run, down, restart, logs, ps, bash, psql
- Django: migrate, makemigrations, showmigrations, createsuperuser,
  collectstatic, shell, dbshell
- Tailwind: install, build, watch
- Testing: pytest unit and E2E targets
- Custom commands: seed, check-content, purge-comments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 14:24:13 +00:00
c028a83bef Merge pull request 'fix: nav/footer wireframe alignment, honeypot CSP fix, comment E2E coverage' (#22) from fix/ui-cleanup into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #22
2026-03-01 12:35:20 +00:00
codex_a
155c8f7569 fix: nav/footer wireframe, honeypot CSP, explore topics, comment E2E coverage
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m11s
CI / ci (pull_request) Successful in 1m25s
- Replace nav inline newsletter form with Subscribe CTA link per wireframe
- Remove newsletter form from footer; add Connect section with social/RSS links
- Fix honeypot inputs using hidden attribute (inline style blocked by CSP)
- Add available_tags to HomePage.get_context for Explore Topics section
- Add data-comment-form attribute to main comment form for reliable locating
- Seed approved comment in E2E content for reply flow testing
- Expand test_comments.py: moderation message, not-immediately-visible,
  missing fields, reply form visible, reply submission
- Make COMMENT_RATE_LIMIT_PER_MINUTE configurable; set 100 in dev to prevent
  E2E test exhaustion; update rate limit unit test with override_settings
- Update newsletter/home E2E tests to reflect nav form removal
- Update unit test to assert no nav/footer newsletter forms

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 12:17:55 +00:00
d83f7db57c Merge pull request 'fix: migrate STATICFILES_STORAGE to STORAGES (Django 5.2)' (#21) from fix/storages-django52 into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
Reviewed-on: #21
2026-03-01 11:51:04 +00:00
codex_a
221c8c19c2 fix: migrate STATICFILES_STORAGE to STORAGES for Django 5.2 compat
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m25s
Django 5.1+ removes STATICFILES_STORAGE in favour of the STORAGES dict.
The old setting was silently ignored on Django 5.2, causing StaticFilesStorage
(the default) to be used instead of CompressedManifestStaticFilesStorage.

Result: no content-hashed filenames, no staticfiles.json manifest, and
Cloudflare caching /static/css/styles.css indefinitely with no cache
busting on deploy.

Fix: use STORAGES in base.py (CompressedManifestStaticFilesStorage) and
development.py (plain StaticFilesStorage, whitenoise disabled in dev).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 11:47:37 +00:00
c0cd4e5037 Merge pull request 'fix: allow Google Fonts in CSP' (#20) from fix/csp-google-fonts into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 23s
Reviewed-on: #20
2026-03-01 11:35:13 +00:00
codex_a
78c4313874 fix: allow Google Fonts in CSP
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m8s
CI / ci (pull_request) Successful in 1m25s
style-src and font-src were 'self' only, blocking fonts.googleapis.com
stylesheet and fonts.gstatic.com font files. Add both origins so
Space Grotesk, Inter and Fira Code load correctly in production.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 11:31:41 +00:00
ec89a5fe35 Merge pull request 'feat: implement Tailwind CSS styling' (#19) from feat/styling into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 22s
Reviewed-on: #19
Reviewed-by: codex_b <codex_b@linteldigital.com>
2026-03-01 11:27:33 +00:00
codex_a
71fe06edd1 fix: address QA review findings
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m6s
CI / ci (pull_request) Successful in 1m24s
1. Typography: install @tailwindcss/typography and add to plugins so prose
   classes render correctly in article/about/legal templates.

2. Callout block: fix icon branches to match CalloutBlock.ICON_CHOICES
   (info/warning/trophy/tip). Previous template branched on error/success
   which are unreachable; info fell through to else silently.

3. Nav newsletter feedback: remove 'hidden' class from desktop nav
   data-newsletter-message element. JS sets textContent only; hidden
   class prevented message from ever being visible.

4. Popular Articles sidebar: add numbered Popular Articles widget to home
   page sidebar matching wireframe, using latest_articles context with
   alternating cyan/pink number accents and read_time_mins.

Rebuild CSS: typography plugin grows output from 24KB to 47KB.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 11:20:57 +00:00
codex_a
bff59eec06 fix: move mobile menu outside nav to prevent playwright strict mode violation
All checks were successful
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Successful in 1m4s
CI / ci (pull_request) Successful in 1m23s
Playwright strict mode requires exactly one match for form[data-newsletter-form]
inside nav. Having desktop + mobile forms both inside <nav> caused a strict
violation. Mobile menu div is now outside the closing </nav> tag; JS toggle
uses getElementById so position doesn't matter.

Rebuild CSS to match template changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:43:17 +00:00
codex_a
8b83712cbf fix: address code review issues and rebuild CSS
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / pr-e2e (pull_request) Failing after 1m4s
CI / ci (pull_request) Successful in 1m22s
- nav: add functional mobile menu panel with JS toggle
- nav: hamburger now shows/hides mobile-menu with aria-expanded state
- about_page: full styled layout (header, prose body, author aside)
- legal_page: full styled layout (header with last-updated, max-w-3xl prose)
- article: fix aside newsletter label to 'Subscribe' for E2E test
- CSS: rebuild after all template changes (4.8KB → 24.3KB)
  Committed CSS must match CI build — rebuilt after ALL template edits

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:25:33 +00:00
codex_a
73ef38a144 fix: resolve E2E test failures
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Failing after 1m23s
CI / pr-e2e (pull_request) Failing after 1m35s
- nav: replace hidden newsletter form + Subscribe link with visible inline form
- nav: fix theme toggle aria-label to 'Toggle theme' (was 'Toggle Dark Mode')
- article_page: wrap share buttons in <section aria-label='Share this article'>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:14:36 +00:00
codex_a
1c7b96f723 feat: implement Tailwind CSS styling based on wireframe design
Some checks failed
CI / nightly-e2e (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / ci (pull_request) Successful in 1m24s
CI / pr-e2e (pull_request) Failing after 3m21s
- Add brand colours, fonts (Space Grotesk/Inter/Fira Code), box shadows to tailwind.config.js
- Add bg-grid-pattern, text-gradient, scrollbar, selection styles to input.css
- Add Google Fonts link and dark-mode body classes to base.html
- Style nav, footer, cookie banner, newsletter form components
- Style homepage: featured article, 12-col editorial grid, sidebar widgets
- Style article list: header, tag filters, horizontal article cards, pagination
- Style article page: hero header, prose body, share sidebar, related cards, comments
- Style code blocks and callout blocks
- CSS output grows from 4.8KB to 24KB with all brand utilities compiled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 10:02:23 +00:00
a880643d65 Merge pull request 'fix: cd to site dir before docker compose commands' (#18) from fix/deploy-workdir into main
All checks were successful
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Successful in 25s
2026-03-01 09:40:43 +00:00
d7bfb44ced Merge pull request 'fix: remove sudo from deploy script' (#17) from fix/deploy-no-sudo into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / deploy (push) Has been skipped
CI / nightly-e2e (push) Failing after 44s
2026-02-28 22:16:30 +00:00
44ffae7f99 Merge pull request 'fix: auto-set Wagtail site hostname on startup' (#16) from fix/prod-site-hostname into main
Some checks failed
CI / ci (push) Has been skipped
CI / pr-e2e (push) Has been skipped
CI / nightly-e2e (push) Has been skipped
CI / deploy (push) Failing after 2m30s
2026-02-28 22:08:29 +00:00
46 changed files with 1592 additions and 209 deletions

View File

@@ -18,6 +18,7 @@ RUN set -eux; \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
libavif-dev \
curl \ curl \
nodejs \ nodejs \
npm \ npm \

123
Makefile Normal file
View File

@@ -0,0 +1,123 @@
DC = docker compose -f /srv/sum/nohype/docker-compose.prod.yml
WEB = $(DC) exec web
MANAGE = $(WEB) python manage.py
.DEFAULT_GOAL := help
# ── Help ──────────────────────────────────────────────────────────────────────
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' \
| sort
# ── Docker ────────────────────────────────────────────────────────────────────
.PHONY: build
build: ## Build / rebuild images
$(DC) build
.PHONY: up
up: ## Start services (detached)
$(DC) up -d
.PHONY: run
run: ## Start services in foreground (with logs)
$(DC) up
.PHONY: down
down: ## Stop and remove containers
$(DC) down
.PHONY: restart
restart: ## Restart all services
$(DC) restart
.PHONY: logs
logs: ## Tail logs for all services (Ctrl-C to stop)
$(DC) logs -f
.PHONY: logs-web
logs-web: ## Tail web service logs
$(DC) logs -f web
.PHONY: ps
ps: ## Show running containers
$(DC) ps
# ── Django ────────────────────────────────────────────────────────────────────
.PHONY: migrate
migrate: ## Apply database migrations
$(MANAGE) migrate --noinput
.PHONY: makemigrations
makemigrations: ## Create new migrations (pass app= to target an app)
$(MANAGE) makemigrations $(app)
.PHONY: showmigrations
showmigrations: ## List all migrations and their status
$(MANAGE) showmigrations
.PHONY: createsuperuser
createsuperuser: ## Create a Django superuser interactively
$(MANAGE) createsuperuser
.PHONY: collectstatic
collectstatic: ## Collect static files
$(MANAGE) collectstatic --noinput
.PHONY: shell
shell: ## Open a Django shell (inside the web container)
$(MANAGE) shell
.PHONY: dbshell
dbshell: ## Open a Django database shell
$(MANAGE) dbshell
.PHONY: bash
bash: ## Open a bash shell inside the web container
$(WEB) bash
.PHONY: psql
psql: ## Open a psql shell in the db container
$(DC) exec db psql -U nohype -d nohype
# ── Tailwind ──────────────────────────────────────────────────────────────────
.PHONY: tailwind-install
tailwind-install: ## Install Tailwind npm dependencies
$(MANAGE) tailwind install --no-input
.PHONY: tailwind-build
tailwind-build: ## Build Tailwind CSS
$(MANAGE) tailwind build
.PHONY: tailwind-watch
tailwind-watch: ## Watch and rebuild Tailwind CSS on changes
$(MANAGE) tailwind start
# ── Testing ───────────────────────────────────────────────────────────────────
.PHONY: test
test: ## Run unit/integration tests with pytest
$(DC) exec web pytest $(args)
.PHONY: test-e2e
test-e2e: ## Run Playwright E2E tests
$(DC) exec web pytest e2e/ $(args)
# ── Custom management commands ────────────────────────────────────────────────
.PHONY: seed
seed: ## Seed deterministic E2E content
$(MANAGE) seed_e2e_content
.PHONY: check-content
check-content: ## Validate live content integrity
$(MANAGE) check_content_integrity
.PHONY: purge-comments
purge-comments: ## Purge old comment personal data (pass months=N to override default 24)
$(MANAGE) purge_old_comment_data $(if $(months),--months $(months),)

View File

@@ -42,6 +42,11 @@ class HomePage(Page):
ctx["featured_article"] = self.featured_article ctx["featured_article"] = self.featured_article
ctx["latest_articles"] = articles ctx["latest_articles"] = articles
ctx["more_articles"] = articles[:3] ctx["more_articles"] = articles[:3]
ctx["available_tags"] = (
Tag.objects.filter(
id__in=ArticlePage.objects.live().public().values_list("tags__id", flat=True)
).distinct().order_by("name")
)
return ctx return ctx
@@ -95,12 +100,12 @@ class TagMetadata(models.Model):
@classmethod @classmethod
def get_fallback_css(cls) -> dict[str, str]: def get_fallback_css(cls) -> dict[str, str]:
return {"bg": "bg-zinc-100", "text": "text-zinc-800"} return {"bg": "bg-zinc-800 dark:bg-zinc-100", "text": "text-white dark:text-black"}
def get_css_classes(self) -> dict[str, str]: def get_css_classes(self) -> dict[str, str]:
mapping = { mapping = {
"cyan": {"bg": "bg-cyan-100", "text": "text-cyan-900"}, "cyan": {"bg": "bg-brand-cyan/10", "text": "text-brand-cyan"},
"pink": {"bg": "bg-pink-100", "text": "text-pink-900"}, "pink": {"bg": "bg-brand-pink/10", "text": "text-brand-pink"},
"neutral": self.get_fallback_css(), "neutral": self.get_fallback_css(),
} }
return mapping.get(self.colour, self.get_fallback_css()) return mapping.get(self.colour, self.get_fallback_css())

View File

@@ -37,6 +37,6 @@ def test_article_compute_read_time_excludes_code(home_page):
def test_tag_metadata_css_and_uniqueness(): def test_tag_metadata_css_and_uniqueness():
tag = Tag.objects.create(name="llms", slug="llms") tag = Tag.objects.create(name="llms", slug="llms")
meta = TagMetadata.objects.create(tag=tag, colour="cyan") meta = TagMetadata.objects.create(tag=tag, colour="cyan")
assert meta.get_css_classes()["bg"].startswith("bg-cyan") assert meta.get_css_classes()["bg"] == "bg-brand-cyan/10"
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
TagMetadata.objects.create(tag=tag, colour="pink") TagMetadata.objects.create(tag=tag, colour="pink")

View File

@@ -69,8 +69,12 @@ def test_newsletter_forms_render_in_nav_and_footer(client, home_page):
resp = client.get("/") resp = client.get("/")
html = resp.content.decode() html = resp.content.decode()
assert resp.status_code == 200 assert resp.status_code == 200
assert 'name="source" value="nav"' in html # Nav has a Subscribe CTA link (no inline form — wireframe spec)
assert 'name="source" value="footer"' in html assert 'href="#newsletter"' in html
# Footer has Connect section with social/RSS links (no newsletter form)
assert "Connect" in html
assert 'name="source" value="nav"' not in html
assert 'name="source" value="footer"' not in html
@pytest.mark.django_db @pytest.mark.django_db

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from django.core.cache import cache from django.core.cache import cache
from django.test import override_settings
from apps.comments.forms import CommentForm from apps.comments.forms import CommentForm
@@ -11,6 +12,7 @@ def test_comment_form_rejects_blank_body():
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(COMMENT_RATE_LIMIT_PER_MINUTE=3)
def test_comment_rate_limit(client, article_page): def test_comment_rate_limit(client, article_page):
cache.clear() cache.clear()
payload = { payload = {

View File

@@ -33,7 +33,8 @@ class CommentCreateView(View):
ip = client_ip_from_request(request) ip = client_ip_from_request(request)
key = f"comment-rate:{ip}" key = f"comment-rate:{ip}"
count = cache.get(key, 0) count = cache.get(key, 0)
if count >= 3: rate_limit = getattr(settings, "COMMENT_RATE_LIMIT_PER_MINUTE", 3)
if count >= rate_limit:
return HttpResponse(status=429) return HttpResponse(status=429)
cache.set(key, count + 1, timeout=60) cache.set(key, count + 1, timeout=60)

View File

@@ -6,6 +6,8 @@ from wagtail.models import Page, Site
from apps.authors.models import Author from apps.authors.models import Author
from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata from apps.blog.models import AboutPage, ArticleIndexPage, ArticlePage, HomePage, TagMetadata
from apps.comments.models import Comment
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
from apps.legal.models import LegalIndexPage, LegalPage from apps.legal.models import LegalIndexPage, LegalPage
@@ -51,7 +53,17 @@ class Command(BaseCommand):
article_index.add_child(instance=article) article_index.add_child(instance=article)
article.save_revision().publish() article.save_revision().publish()
# Tagged article — used by tag-filter E2E tests # Seed one approved top-level comment on the primary article for reply E2E tests
if not Comment.objects.filter(article=article, author_name="E2E Approved Commenter").exists():
Comment.objects.create(
article=article,
author_name="E2E Approved Commenter",
author_email="approved@example.com",
body="This is a seeded approved comment for reply testing.",
is_approved=True,
)
tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools") tag, _ = Tag.objects.get_or_create(name="AI Tools", slug="ai-tools")
TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"}) TagMetadata.objects.get_or_create(tag=tag, defaults={"colour": "cyan"})
tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first() tagged_article = ArticlePage.objects.child_of(article_index).filter(slug="e2e-tagged-article").first()
@@ -128,4 +140,45 @@ class Command(BaseCommand):
site.is_default_site = True site.is_default_site = True
site.save() site.save()
# Navigation menu items and social links — always reconcile to
# match the pages we just created (the data migration may have
# seeded partial items before these pages existed).
settings, _ = SiteSettings.objects.get_or_create(site=site)
NavigationMenuItem.objects.filter(settings=settings).delete()
article_index_page = ArticleIndexPage.objects.child_of(home).filter(slug="articles").first()
about_page = AboutPage.objects.child_of(home).filter(slug="about").first()
nav_items = [
NavigationMenuItem(settings=settings, link_page=home, link_title="Home", sort_order=0),
]
if article_index_page:
nav_items.append(
NavigationMenuItem(
settings=settings, link_page=article_index_page,
link_title="Articles", sort_order=1,
)
)
if about_page:
nav_items.append(
NavigationMenuItem(
settings=settings, link_page=about_page,
link_title="About", sort_order=2,
)
)
NavigationMenuItem.objects.bulk_create(nav_items)
SocialMediaLink.objects.filter(settings=settings).delete()
SocialMediaLink.objects.bulk_create(
[
SocialMediaLink(
settings=settings, platform="twitter",
url="https://twitter.com/nohypeai",
label="Twitter (X)", sort_order=0,
),
SocialMediaLink(
settings=settings, platform="rss",
url="/feed/", label="RSS Feed", sort_order=1,
),
]
)
self.stdout.write(self.style.SUCCESS("Seeded E2E content.")) self.stdout.write(self.style.SUCCESS("Seeded E2E content."))

View File

@@ -18,16 +18,20 @@ 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'; " "style-src 'self' https://fonts.googleapis.com; "
"img-src 'self' data: blob:; " "img-src 'self' data: blob:; "
"font-src 'self'; " "font-src 'self' https://fonts.gstatic.com; "
"connect-src 'self'; " "connect-src 'self'; "
"object-src 'none'; " "object-src 'none'; "
"base-uri 'self'; " "base-uri 'self'; "

View File

@@ -0,0 +1,69 @@
# Generated by Django 5.2.11 on 2026-03-02 18:39
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
('wagtailcore', '0094_alter_page_locale'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='copyright_text',
field=models.CharField(default='No Hype AI. All rights reserved.', max_length=200),
),
migrations.AddField(
model_name='sitesettings',
name='footer_description',
field=models.TextField(blank=True, default='In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.'),
),
migrations.AddField(
model_name='sitesettings',
name='site_name',
field=models.CharField(default='NO HYPE AI', max_length=100),
),
migrations.AddField(
model_name='sitesettings',
name='tagline',
field=models.CharField(default='Honest AI tool reviews for developers.', max_length=200),
),
migrations.CreateModel(
name='NavigationMenuItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('link_url', models.URLField(blank=True, default='', help_text='External URL (used only when no page is selected).')),
('link_title', models.CharField(blank=True, default='', help_text='Override the display text. If blank, the page title is used.', max_length=100)),
('open_in_new_tab', models.BooleanField(default=False)),
('show_in_header', models.BooleanField(default=True)),
('show_in_footer', models.BooleanField(default=True)),
('link_page', models.ForeignKey(blank=True, help_text='Link to an internal page. If unpublished, the link is hidden automatically.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailcore.page')),
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='navigation_items', to='core.sitesettings')),
],
options={
'ordering': ['sort_order'],
'abstract': False,
},
),
migrations.CreateModel(
name='SocialMediaLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('platform', models.CharField(choices=[('twitter', 'Twitter / X'), ('github', 'GitHub'), ('rss', 'RSS Feed'), ('linkedin', 'LinkedIn'), ('youtube', 'YouTube'), ('mastodon', 'Mastodon'), ('bluesky', 'Bluesky')], max_length=30)),
('url', models.URLField()),
('label', models.CharField(blank=True, default='', help_text='Display label. If blank, the platform name is used.', max_length=100)),
('settings', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_links', to='core.sitesettings')),
],
options={
'ordering': ['sort_order'],
'abstract': False,
},
),
]

View File

@@ -0,0 +1,112 @@
# Generated by Django 5.2.11 on 2026-03-02 18:39
from django.db import migrations
def seed_navigation_data(apps, schema_editor):
Site = apps.get_model("wagtailcore", "Site")
SiteSettings = apps.get_model("core", "SiteSettings")
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
Page = apps.get_model("wagtailcore", "Page")
for site in Site.objects.all():
settings, _ = SiteSettings.objects.get_or_create(site=site)
# Only seed if no nav items exist yet
if NavigationMenuItem.objects.filter(settings=settings).exists():
continue
root_page = site.root_page
if not root_page:
continue
# Find pages by slug under the site root using tree path
home_page = root_page
# In Wagtail's treebeard, direct children share the root's path prefix
articles_page = Page.objects.filter(
depth=root_page.depth + 1,
path__startswith=root_page.path,
slug__startswith="articles",
).first()
about_page = Page.objects.filter(
depth=root_page.depth + 1,
path__startswith=root_page.path,
slug__startswith="about",
).first()
nav_items = []
if home_page:
nav_items.append(
NavigationMenuItem(
settings=settings,
link_page=home_page,
link_title="Home",
show_in_header=True,
show_in_footer=True,
sort_order=0,
)
)
if articles_page:
nav_items.append(
NavigationMenuItem(
settings=settings,
link_page=articles_page,
link_title="Articles",
show_in_header=True,
show_in_footer=True,
sort_order=1,
)
)
if about_page:
nav_items.append(
NavigationMenuItem(
settings=settings,
link_page=about_page,
link_title="About",
show_in_header=True,
show_in_footer=True,
sort_order=2,
)
)
NavigationMenuItem.objects.bulk_create(nav_items)
# Social links
if not SocialMediaLink.objects.filter(settings=settings).exists():
SocialMediaLink.objects.bulk_create(
[
SocialMediaLink(
settings=settings,
platform="twitter",
url="https://twitter.com/nohypeai",
label="Twitter (X)",
sort_order=0,
),
SocialMediaLink(
settings=settings,
platform="rss",
url="/feed/",
label="RSS Feed",
sort_order=1,
),
]
)
def reverse_seed(apps, schema_editor):
NavigationMenuItem = apps.get_model("core", "NavigationMenuItem")
SocialMediaLink = apps.get_model("core", "SocialMediaLink")
NavigationMenuItem.objects.all().delete()
SocialMediaLink.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0002_sitesettings_copyright_text_and_more'),
('wagtailcore', '0094_alter_page_locale'),
]
operations = [
migrations.RunPython(seed_navigation_data, reverse_seed),
]

View File

@@ -1,10 +1,24 @@
from django.db import models from django.db import models
from django.db.models import SET_NULL from django.db.models import SET_NULL
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.contrib.settings.models import BaseSiteSetting, register_setting from wagtail.contrib.settings.models import BaseSiteSetting, register_setting
from wagtail.models import Orderable
SOCIAL_ICON_CHOICES = [
("twitter", "Twitter / X"),
("github", "GitHub"),
("rss", "RSS Feed"),
("linkedin", "LinkedIn"),
("youtube", "YouTube"),
("mastodon", "Mastodon"),
("bluesky", "Bluesky"),
]
@register_setting @register_setting
class SiteSettings(BaseSiteSetting): class SiteSettings(ClusterableModel, BaseSiteSetting):
default_og_image = models.ForeignKey( default_og_image = models.ForeignKey(
"wagtailimages.Image", "wagtailimages.Image",
null=True, null=True,
@@ -19,3 +33,140 @@ class SiteSettings(BaseSiteSetting):
on_delete=SET_NULL, on_delete=SET_NULL,
related_name="+", related_name="+",
) )
# Branding
site_name = models.CharField(max_length=100, default="NO HYPE AI")
tagline = models.CharField(
max_length=200,
default="Honest AI tool reviews for developers.",
)
footer_description = models.TextField(
default="In-depth reviews and benchmarks of the latest AI coding tools.\nHonest analysis for developers.",
blank=True,
)
copyright_text = models.CharField(
max_length=200,
default="No Hype AI. All rights reserved.",
)
panels = [
MultiFieldPanel(
[
FieldPanel("site_name"),
FieldPanel("tagline"),
FieldPanel("footer_description"),
FieldPanel("copyright_text"),
],
heading="Branding",
),
MultiFieldPanel(
[
FieldPanel("default_og_image"),
FieldPanel("privacy_policy_page"),
],
heading="SEO & Legal",
),
InlinePanel("navigation_items", label="Navigation Menu Items"),
InlinePanel("social_links", label="Social Media Links"),
]
class NavigationMenuItem(Orderable):
settings = ParentalKey(
SiteSettings,
on_delete=models.CASCADE,
related_name="navigation_items",
)
link_page = models.ForeignKey(
"wagtailcore.Page",
null=True,
blank=True,
on_delete=SET_NULL,
related_name="+",
help_text="Link to an internal page. If unpublished, the link is hidden automatically.",
)
link_url = models.URLField(
blank=True,
default="",
help_text="External URL (used only when no page is selected).",
)
link_title = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Override the display text. If blank, the page title is used.",
)
open_in_new_tab = models.BooleanField(default=False)
show_in_header = models.BooleanField(default=True)
show_in_footer = models.BooleanField(default=True)
panels = [
FieldPanel("link_page"),
FieldPanel("link_url"),
FieldPanel("link_title"),
FieldPanel("open_in_new_tab"),
FieldPanel("show_in_header"),
FieldPanel("show_in_footer"),
]
@property
def title(self):
if self.link_title:
return self.link_title
if self.link_page:
return self.link_page.title
return ""
@property
def url(self):
if self.link_page:
return self.link_page.url
return self.link_url
@property
def is_live(self):
"""Return False if linked to an unpublished/non-live page."""
if self.link_page_id:
return self.link_page.live
return bool(self.link_url)
class Meta(Orderable.Meta):
pass
class SocialMediaLink(Orderable):
settings = ParentalKey(
SiteSettings,
on_delete=models.CASCADE,
related_name="social_links",
)
platform = models.CharField(
max_length=30,
choices=SOCIAL_ICON_CHOICES,
)
url = models.URLField()
label = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Display label. If blank, the platform name is used.",
)
panels = [
FieldPanel("platform"),
FieldPanel("url"),
FieldPanel("label"),
]
@property
def display_label(self):
if self.label:
return self.label
return dict(SOCIAL_ICON_CHOICES).get(self.platform, self.platform)
@property
def icon_template(self):
return f"components/icons/{self.platform}.html"
class Meta(Orderable.Meta):
pass

View File

@@ -5,6 +5,7 @@ from django.utils.safestring import mark_safe
from wagtail.models import Site from wagtail.models import Site
from apps.blog.models import TagMetadata from apps.blog.models import TagMetadata
from apps.core.models import SiteSettings
from apps.legal.models import LegalPage from apps.legal.models import LegalPage
register = template.Library() register = template.Library()
@@ -20,6 +21,31 @@ def get_legal_pages(context):
return pages return pages
@register.simple_tag(takes_context=True)
def get_nav_items(context, location="header"):
request = context.get("request")
site = Site.find_for_request(request) if request else None
settings = SiteSettings.for_site(site) if site else None
if not settings:
return []
items = settings.navigation_items.all()
if location == "header":
items = items.filter(show_in_header=True)
elif location == "footer":
items = items.filter(show_in_footer=True)
return [item for item in items if item.is_live]
@register.simple_tag(takes_context=True)
def get_social_links(context):
request = context.get("request")
site = Site.find_for_request(request) if request else None
settings = SiteSettings.for_site(site) if site else None
if not settings:
return []
return list(settings.social_links.all())
@register.simple_tag @register.simple_tag
@register.filter @register.filter
def get_tag_css(tag): def get_tag_css(tag):

View File

@@ -0,0 +1,191 @@
import pytest
from wagtail.models import Site
from apps.blog.models import AboutPage, ArticleIndexPage
from apps.core.models import NavigationMenuItem, SiteSettings, SocialMediaLink
@pytest.fixture
def site_with_nav(home_page):
"""Create SiteSettings with nav items and social links for testing."""
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
# Clear any items seeded by the data migration
settings.navigation_items.all().delete()
settings.social_links.all().delete()
# Create article index and about page
article_index = ArticleIndexPage(title="Articles", slug="articles")
home_page.add_child(instance=article_index)
article_index.save_revision().publish()
about = AboutPage(
title="About",
slug="about",
mission_statement="Test mission",
body="<p>About page</p>",
)
home_page.add_child(instance=about)
about.save_revision().publish()
# Create nav items
NavigationMenuItem.objects.create(
settings=settings,
link_page=home_page,
link_title="Home",
show_in_header=True,
show_in_footer=True,
sort_order=0,
)
NavigationMenuItem.objects.create(
settings=settings,
link_page=article_index,
link_title="Articles",
show_in_header=True,
show_in_footer=True,
sort_order=1,
)
NavigationMenuItem.objects.create(
settings=settings,
link_page=about,
link_title="About",
show_in_header=True,
show_in_footer=False,
sort_order=2,
)
# Social links
SocialMediaLink.objects.create(
settings=settings,
platform="twitter",
url="https://twitter.com/nohypeai",
label="Twitter (X)",
sort_order=0,
)
SocialMediaLink.objects.create(
settings=settings,
platform="rss",
url="/feed/",
label="RSS Feed",
sort_order=1,
)
return settings
@pytest.mark.django_db
class TestNavigationMenuItem:
def test_live_page_is_rendered(self, site_with_nav):
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
assert len(items) == 3
def test_unpublished_page_excluded(self, site_with_nav):
about_item = site_with_nav.navigation_items.get(link_title="About")
about_item.link_page.unpublish()
items = [i for i in site_with_nav.navigation_items.all() if i.is_live]
assert len(items) == 2
assert all(i.link_title != "About" for i in items)
def test_external_url_item(self, site_with_nav):
NavigationMenuItem.objects.create(
settings=site_with_nav,
link_url="https://example.com",
link_title="External",
sort_order=10,
)
item = site_with_nav.navigation_items.get(link_title="External")
assert item.is_live is True
assert item.url == "https://example.com"
assert item.title == "External"
def test_title_falls_back_to_page_title(self, site_with_nav):
item = site_with_nav.navigation_items.get(sort_order=0)
item.link_title = ""
item.save()
assert item.title == item.link_page.title
def test_header_footer_filtering(self, site_with_nav):
header_items = site_with_nav.navigation_items.filter(show_in_header=True)
footer_items = site_with_nav.navigation_items.filter(show_in_footer=True)
assert header_items.count() == 3
assert footer_items.count() == 2 # About excluded from footer
def test_sort_order_respected(self, site_with_nav):
items = list(site_with_nav.navigation_items.all().order_by("sort_order"))
assert [i.link_title for i in items] == ["Home", "Articles", "About"]
@pytest.mark.django_db
class TestSocialMediaLink:
def test_display_label_from_field(self, site_with_nav):
link = site_with_nav.social_links.get(platform="twitter")
assert link.display_label == "Twitter (X)"
def test_display_label_fallback(self, site_with_nav):
link = site_with_nav.social_links.get(platform="twitter")
link.label = ""
assert link.display_label == "Twitter / X"
def test_icon_template_path(self, site_with_nav):
link = site_with_nav.social_links.get(platform="rss")
assert link.icon_template == "components/icons/rss.html"
def test_ordering(self, site_with_nav):
links = list(site_with_nav.social_links.all().order_by("sort_order"))
assert [link.platform for link in links] == ["twitter", "rss"]
@pytest.mark.django_db
class TestSiteSettingsDefaults:
def test_default_site_name(self, home_page):
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
assert settings.site_name == "NO HYPE AI"
def test_default_copyright(self, home_page):
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
assert settings.copyright_text == "No Hype AI. All rights reserved."
def test_default_tagline(self, home_page):
site = Site.objects.get(is_default_site=True)
settings, _ = SiteSettings.objects.get_or_create(site=site)
assert settings.tagline == "Honest AI tool reviews for developers."
@pytest.mark.django_db
class TestNavRendering:
def test_header_shows_nav_items(self, client, site_with_nav):
resp = client.get("/")
content = resp.content.decode()
assert "Home" in content
assert "Articles" in content
assert "About" in content
def test_unpublished_page_not_in_header(self, client, site_with_nav):
about_item = site_with_nav.navigation_items.get(link_title="About")
about_item.link_page.unpublish()
resp = client.get("/")
content = resp.content.decode()
# About should not appear as a nav link (but might appear elsewhere on page)
assert 'href="/about/"' not in content
def test_footer_shows_nav_items(self, client, site_with_nav):
resp = client.get("/")
content = resp.content.decode()
# Footer should have social links
assert "Twitter (X)" in content
assert "RSS Feed" in content
def test_footer_shows_branding(self, client, site_with_nav):
site_with_nav.site_name = "TEST SITE"
site_with_nav.save()
resp = client.get("/")
content = resp.content.decode()
assert "TEST SITE" in content
def test_footer_shows_copyright(self, client, site_with_nav):
resp = client.get("/")
content = resp.content.decode()
assert "No Hype AI. All rights reserved." in content

View File

@@ -142,6 +142,13 @@ X_CONTENT_TYPE_OPTIONS = "nosniff"
CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u] CSRF_TRUSTED_ORIGINS = [u for u in os.getenv("CSRF_TRUSTED_ORIGINS", "http://localhost:8035").split(",") if u]
TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()] TRUSTED_PROXY_IPS = [ip.strip() for ip in os.getenv("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
TAILWIND_APP_NAME = "theme" TAILWIND_APP_NAME = "theme"

View File

@@ -9,7 +9,15 @@ INTERNAL_IPS = ["127.0.0.1"]
# media files natively when DEBUG=True (via django.contrib.staticfiles + the # media files natively when DEBUG=True (via django.contrib.staticfiles + the
# media URL pattern in urls.py). # media URL pattern in urls.py).
MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"] MIDDLEWARE = [m for m in MIDDLEWARE if m != "whitenoise.middleware.WhiteNoiseMiddleware"]
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
try: try:
import debug_toolbar # noqa: F401 import debug_toolbar # noqa: F401
@@ -18,3 +26,5 @@ try:
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE] MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
except Exception: except Exception:
pass pass
COMMENT_RATE_LIMIT_PER_MINUTE = 100

View File

@@ -12,39 +12,87 @@ def _go_to_article(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle") page.goto(f"{base_url}/articles/{ARTICLE_SLUG}/", wait_until="networkidle")
@pytest.mark.e2e def _submit_comment(page: Page, *, name: str = "E2E Tester", email: str = "e2e@example.com", body: str) -> None:
def test_valid_comment_submission_redirects(page: Page, base_url: str) -> None: """Fill and submit the main (non-reply) comment form."""
_go_to_article(page, base_url) form = page.locator("form[data-comment-form]")
form.locator('input[name="author_name"]').fill(name)
# Fill the main comment form (not a reply form) form.locator('input[name="author_email"]').fill(email)
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment")) form.locator('textarea[name="body"]').fill(body)
form.locator('input[name="author_name"]').fill("E2E Tester")
form.locator('input[name="author_email"]').fill("e2e@example.com")
form.locator('textarea[name="body"]').fill("This is a test comment from Playwright.")
form.get_by_role("button", name="Post comment").click() form.get_by_role("button", name="Post comment").click()
# Successful submission redirects back to the article with ?commented=1
@pytest.mark.e2e
def test_valid_comment_shows_moderation_message(page: Page, base_url: str) -> None:
"""Successful comment submission must show the awaiting-moderation banner."""
_go_to_article(page, base_url)
_submit_comment(page, body="This is a test comment from Playwright.")
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000) page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
assert "commented=1" in page.url expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
@pytest.mark.e2e
def test_valid_comment_not_immediately_visible(page: Page, base_url: str) -> None:
"""Submitted comment must NOT appear in the comments list before moderation."""
_go_to_article(page, base_url)
unique_body = "Unique unmoderated comment body xq7z"
_submit_comment(page, body=unique_body)
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
expect(page.get_by_text(unique_body)).not_to_be_visible()
@pytest.mark.e2e @pytest.mark.e2e
def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None: def test_empty_body_shows_form_errors(page: Page, base_url: str) -> None:
_go_to_article(page, base_url) _go_to_article(page, base_url)
_submit_comment(page, body=" ") # whitespace-only body
page.wait_for_load_state("networkidle")
form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Post comment")) expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
form.locator('input[name="author_name"]').fill("E2E Tester") assert "commented=1" not in page.url
@pytest.mark.e2e
def test_missing_name_shows_form_errors(page: Page, base_url: str) -> None:
_go_to_article(page, base_url)
form = page.locator("form[data-comment-form]")
form.locator('input[name="author_name"]').fill("")
form.locator('input[name="author_email"]').fill("e2e@example.com") form.locator('input[name="author_email"]').fill("e2e@example.com")
form.locator('textarea[name="body"]').fill(" ") # whitespace-only body form.locator('textarea[name="body"]').fill("Comment without a name.")
form.get_by_role("button", name="Post comment").click() form.get_by_role("button", name="Post comment").click()
page.wait_for_load_state("networkidle") page.wait_for_load_state("networkidle")
# The page re-renders with the error summary visible
expect(page.locator('[aria-label="Comment form errors"]')).to_be_visible()
# URL must NOT have ?commented=1 — form was not accepted
assert "commented=1" not in page.url assert "commented=1" not in page.url
@pytest.mark.e2e
def test_reply_form_visible_on_approved_comment(page: Page, base_url: str) -> None:
"""An approved seeded comment must display a reply form."""
_go_to_article(page, base_url)
# The seeded approved comment should be visible
expect(page.get_by_text("E2E Approved Commenter")).to_be_visible()
# And a Reply button for it
expect(page.get_by_role("button", name="Reply")).to_be_visible()
@pytest.mark.e2e
def test_reply_submission_redirects(page: Page, base_url: str) -> None:
"""Submitting a reply to an approved comment should redirect with commented=1."""
_go_to_article(page, base_url)
# The reply form is always visible below the approved seeded comment
reply_form = page.locator("form[action]").filter(has=page.get_by_role("button", name="Reply")).first
reply_form.locator('input[name="author_name"]').fill("E2E Replier")
reply_form.locator('input[name="author_email"]').fill("replier@example.com")
reply_form.locator('textarea[name="body"]').fill("This is a test reply.")
reply_form.get_by_role("button", name="Reply").click()
page.wait_for_url(lambda url: "commented=1" in url, timeout=10_000)
expect(page.get_by_text("Your comment is awaiting moderation")).to_be_visible()
@pytest.mark.e2e @pytest.mark.e2e
def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None: def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> None:
"""Article with comments_enabled=False must not show the comments section.""" """Article with comments_enabled=False must not show the comments section."""
@@ -52,8 +100,7 @@ def test_comments_section_absent_when_disabled(page: Page, base_url: str) -> Non
assert response is not None and response.status == 200, ( assert response is not None and response.status == 200, (
f"Expected 200 for e2e-no-comments article, got {response and response.status}" f"Expected 200 for e2e-no-comments article, got {response and response.status}"
) )
# Confirm we're on the right page
expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article") expect(page.get_by_role("heading", level=1)).to_have_text("No Comments Article")
# Comments section must be absent — exact=True prevents matching "No Comments Article" h1
expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0) expect(page.get_by_role("heading", name="Comments", exact=True)).to_have_count(0)
expect(page.get_by_role("button", name="Post comment")).to_have_count(0) expect(page.get_by_role("button", name="Post comment")).to_have_count(0)

View File

@@ -37,12 +37,11 @@ def test_theme_toggle_adds_dark_class(page: Page, base_url: str) -> None:
@pytest.mark.e2e @pytest.mark.e2e
def test_newsletter_form_in_nav(page: Page, base_url: str) -> None: def test_nav_subscribe_cta_present(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/", wait_until="networkidle") page.goto(f"{base_url}/", wait_until="networkidle")
# The nav contains a newsletter form with an email input
nav = page.locator("nav") nav = page.locator("nav")
expect(nav.locator('input[type="email"]')).to_be_visible() # Nav has a Subscribe CTA link (not a form — wireframe spec)
expect(nav.get_by_role("button", name="Subscribe")).to_be_visible() expect(nav.get_by_role("link", name="Subscribe")).to_be_visible()
@pytest.mark.e2e @pytest.mark.e2e

View File

@@ -7,7 +7,8 @@ from playwright.sync_api import Page, expect
def _nav_newsletter_form(page: Page): def _nav_newsletter_form(page: Page):
return page.locator("nav").locator("form[data-newsletter-form]") """Return the newsletter form in the home page sidebar aside."""
return page.locator("aside").locator("form[data-newsletter-form]").first
@pytest.mark.e2e @pytest.mark.e2e
@@ -28,7 +29,7 @@ def test_subscribe_invalid_email_shows_error(page: Page, base_url: str) -> None:
form = _nav_newsletter_form(page) form = _nav_newsletter_form(page)
# Disable the browser's native HTML5 email validation so the JS handler # Disable the browser's native HTML5 email validation so the JS handler
# fires and sends the bad value to the server (which returns 400). # fires and sends the bad value to the server (which returns 400).
page.evaluate("document.querySelector('nav form[data-newsletter-form]').setAttribute('novalidate', '')") page.evaluate("document.querySelector('aside form[data-newsletter-form]').setAttribute('novalidate', '')")
form.locator('input[type="email"]').fill("not-an-email") form.locator('input[type="email"]').fill("not-an-email")
form.get_by_role("button", name="Subscribe").click() form.get_by_role("button", name="Subscribe").click()

View File

@@ -2,7 +2,7 @@ Django~=5.2.0
wagtail~=7.0.0 wagtail~=7.0.0
wagtail-seo~=3.1.1 wagtail-seo~=3.1.1
psycopg2-binary~=2.9.0 psycopg2-binary~=2.9.0
Pillow~=11.0.0 Pillow~=12.1
django-taggit~=6.0.0 django-taggit~=6.0.0
whitenoise~=6.0.0 whitenoise~=6.0.0
gunicorn~=23.0.0 gunicorn~=23.0.0

4
static/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#09090b"/>
<text x="16" y="24" text-anchor="middle" font-family="'Space Grotesk',sans-serif" font-weight="700" font-size="22" fill="#fafafa">/</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -6,6 +6,10 @@
<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){}})();
@@ -15,17 +19,18 @@
<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> <body class="bg-brand-light dark:bg-brand-dark text-brand-dark dark:text-brand-light antialiased min-h-screen flex flex-col relative">
<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"> <section aria-label="Messages" class="max-w-7xl mx-auto px-6 py-2">
{% for message in messages %} {% for message in messages %}
<p>{{ message }}</p> <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>
{% endfor %} {% endfor %}
</section> </section>
{% endif %} {% endif %}
<main>{% block content %}{% endblock %}</main> <main class="flex-grow w-full max-w-7xl mx-auto px-6 py-8">{% block content %}{% endblock %}</main>
{% include 'components/footer.html' %} {% include 'components/footer.html' %}
</body> </body>
</html> </html>

View File

@@ -1,14 +1,40 @@
{% 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>
<p>{{ page.mission_statement }}</p> <!-- Page Header -->
{{ page.body|richtext }} <div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
{% if page.featured_author %} <h1 class="font-display font-black text-4xl md:text-6xl mb-4">{{ page.title }}</h1>
<h2>{{ page.featured_author.name }}</h2> {% if page.mission_statement %}
<p>{{ page.featured_author.bio }}</p> <p class="text-xl md:text-2xl text-zinc-600 dark:text-zinc-400 font-medium max-w-2xl">{{ page.mission_statement }}</p>
{% if page.featured_author.avatar %}
{% image page.featured_author.avatar fill-200x200 %}
{% endif %} {% endif %}
{% endif %} </div>
<!-- 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 %}

View File

@@ -10,26 +10,39 @@
<meta property="og:url" content="{{ canonical }}" /> <meta property="og:url" content="{{ canonical }}" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>{{ page.title }}</h1>
<section> <!-- Page Header -->
<h2>Filter by tag</h2> <div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
<a href="/articles/" {% if not active_tag %}aria-current="page"{% endif %}>All</a> <h1 class="font-display font-black text-4xl md:text-6xl mb-6">{{ page.title }}</h1>
<!-- Tag Filters -->
<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 %} {% for tag in available_tags %}
<a href="/articles/?tag={{ tag.slug }}" {% if active_tag == tag.slug %}aria-current="page"{% endif %}>{{ tag.name }}</a> <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 %} {% endfor %}
</section> </div>
{% for article in articles %} </div>
<!-- Article List -->
<div class="space-y-8">
{% for article in articles %}
{% include 'components/article_card.html' with article=article %} {% include 'components/article_card.html' with article=article %}
{% empty %} {% empty %}
<p>No articles found.</p> <p class="font-mono text-zinc-500 py-12 text-center">No articles found.</p>
{% endfor %} {% endfor %}
<nav aria-label="Pagination"> </div>
<!-- Pagination -->
{% if articles.has_previous or articles.has_next %}
<nav aria-label="Pagination" class="mt-12 flex justify-center items-center gap-4 font-mono text-sm">
{% if articles.has_previous %} {% if articles.has_previous %}
<a href="?page={{ articles.previous_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Previous</a> <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>
{% endif %} {% endif %}
<span>Page {{ articles.number }} of {{ paginator.num_pages }}</span> <span class="text-zinc-500">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 %}">Next</a> <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>
{% endif %} {% endif %}
</nav> </nav>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load wagtailcore_tags wagtailimages_tags seo_tags %} {% load wagtailcore_tags wagtailimages_tags seo_tags core_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,75 +17,209 @@
{% 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>
<h1>{{ page.title }}</h1> <!-- Breadcrumb -->
<p>{{ page.read_time_mins }} min read</p> <div class="mb-8 font-mono text-sm text-zinc-500">
{% if page.hero_image %} <a href="/" class="hover:text-brand-cyan transition-colors">Home</a> /
{% image page.hero_image fill-1200x630 %} <a href="/articles/" class="hover:text-brand-cyan transition-colors">Articles</a> /
{% endif %} <span class="text-brand-dark dark:text-brand-light">{{ page.title|truncatechars:40 }}</span>
</div>
<!-- Article Header -->
<header class="mb-12 border-b border-zinc-200 dark:border-zinc-800 pb-12">
<div class="flex gap-3 mb-6 items-center flex-wrap">
{% for tag in page.tags.all %}
<span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }} border border-current/20">{{ tag.name }}</span>
{% endfor %}
<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 }} {{ page.body }}
{% article_json_ld page %} {% article_json_ld page %}
</article> </article>
<section aria-label="Share this article">
<h2>Share</h2> <!-- Sidebar -->
<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> <aside class="lg:col-span-4 space-y-8">
<a href="https://www.linkedin.com/sharing/share-offsite/?url={{ request.build_absolute_uri|urlencode }}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a> <div class="sticky top-28">
<button type="button" data-copy-link data-copy-url="{{ request.build_absolute_uri }}">Copy link</button>
</section> <!-- Share -->
<section> <section aria-label="Share this article" class="mb-8">
<h2>Related</h2> <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 %} {% for article in related_articles %}
<a href="{{ article.url }}">{{ article.title }}</a> <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 %} {% endfor %}
</section> </div>
<aside> <a href="{{ article.url }}">
<h2>Newsletter</h2> <h4 class="font-display font-bold text-xl mb-2 group-hover:text-brand-cyan transition-colors">{{ article.title }}</h4>
{% include 'components/newsletter_form.html' with source='article' label='Never miss a post' %} </a>
</aside> <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6 line-clamp-2">{{ article.summary }}</p>
{% if page.comments_enabled %} <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">
<section> Read Article
<h2>Comments</h2> <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>
{% for comment in approved_comments %} </div>
<article id="comment-{{ comment.id }}"> </div>
<p><strong>{{ comment.author_name }}</strong></p>
<p>{{ comment.body }}</p>
{% for reply in comment.replies.all %}
<article id="comment-{{ reply.id }}">
<p><strong>{{ reply.author_name }}</strong></p>
<p>{{ reply.body }}</p>
</article> </article>
{% endfor %} {% endfor %}
<form method="post" action="{% url 'comment_post' %}"> </div>
</section>
{% endif %}
<!-- Comments -->
{% if page.comments_enabled %}
<section class="mt-16 pt-12 border-t border-zinc-200 dark:border-zinc-800">
<h2 class="font-display font-bold text-3xl mb-8">Comments</h2>
{% if approved_comments %}
<div class="space-y-8 mb-12">
{% for comment in approved_comments %}
<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 %}
<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">
<div class="flex items-center gap-3 mb-2">
<div class="w-6 h-6 bg-gradient-to-tr from-brand-pink to-brand-cyan shrink-0"></div>
<div>
<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 %}
<form method="post" action="{% url 'comment_post' %}" class="mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
{% 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 }}" />
<input type="text" name="author_name" required /> <div class="flex gap-3 mb-3">
<input type="email" name="author_email" required /> <input type="text" name="author_name" required placeholder="Your name"
<textarea name="body" required></textarea> 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" />
<input type="text" name="honeypot" style="display:none" /> <input type="email" name="author_email" required placeholder="your@email.com"
<button type="submit">Reply</button> 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" />
</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>
{% empty %}
<p>No comments yet.</p>
{% endfor %}
{% if comment_form and comment_form.errors %}
<div aria-label="Comment form errors">
{{ comment_form.non_field_errors }}
{% for field in comment_form %}
{{ field.errors }}
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<p class="font-mono text-sm text-zinc-500 mb-12">No comments yet. Be the first to comment.</p>
{% endif %} {% endif %}
<form method="post" action="{% url 'comment_post' %}">
{% 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">
{{ comment_form.non_field_errors }}
{% for field in comment_form %}{{ field.errors }}{% endfor %}
</div>
{% endif %}
<div class="bg-brand-surfaceLight dark:bg-brand-surfaceDark border border-zinc-200 dark:border-zinc-800 p-6">
<h3 class="font-display font-bold text-xl mb-6">Post a Comment</h3>
<form method="post" action="{% url 'comment_post' %}" data-comment-form class="space-y-4">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="article_id" value="{{ page.id }}" /> <input type="hidden" name="article_id" value="{{ page.id }}" />
<input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required /> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="email" name="author_email" value="{% if comment_form %}{{ comment_form.author_email.value|default:'' }}{% endif %}" required /> <div>
<textarea name="body" required>{% if comment_form %}{{ comment_form.body.value|default:'' }}{% endif %}</textarea> <label class="block font-mono text-xs text-zinc-500 mb-1 uppercase tracking-wider">Name *</label>
<input type="text" name="honeypot" style="display:none" /> <input type="text" name="author_name" value="{% if comment_form %}{{ comment_form.author_name.value|default:'' }}{% endif %}" required
<button type="submit">Post comment</button> 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> </form>
</div>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,4 +1,20 @@
<div class="callout icon-{{ value.icon }}"> <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
<h3>{{ value.heading }}</h3> {% 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 %}">
{{ value.body }} <div class="shrink-0 mt-0.5">
{% 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>

View File

@@ -1,5 +1,19 @@
{% load wagtailcore_tags %} {% load wagtailcore_tags %}
<div class="code-block"> <div class="my-8 overflow-hidden bg-[#0d1117] border border-zinc-800 shadow-xl">
{% if value.filename %}<div>{{ value.filename }}</div>{% endif %} <div class="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-zinc-800">
<pre data-lang="{{ value.language }}"><code class="language-{{ value.language }}">{{ value.raw_code }}</code></pre> <div class="flex gap-2">
<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>

View File

@@ -1,5 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load seo_tags %} {% load wagtailimages_tags seo_tags core_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,21 +11,146 @@
<meta property="og:url" content="{{ canonical }}" /> <meta property="og:url" content="{{ canonical }}" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<section>
{% if featured_article %} <!-- Featured Article -->
<h2>{{ featured_article.title }}</h2> {% if featured_article %}
<p>{{ featured_article.author.name }}</p> <section class="mb-12 md:mb-16">
<p>{{ featured_article.read_time_mins }} min read</p> <div class="flex items-center gap-2 mb-6">
<span class="w-2 h-2 rounded-full bg-brand-pink animate-pulse"></span>
<span class="font-mono text-sm font-bold uppercase tracking-widest text-zinc-500">Featured Article</span>
</div>
<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">
<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">
{% if featured_article.hero_image %}
{% 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" %}
{% else %}
<div class="absolute inset-0 flex items-center justify-center">
<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>
</div>
{% endif %} {% endif %}
</section> </a>
<section> <div class="flex flex-col py-2 order-1 lg:order-2">
{% for article in latest_articles %} <div class="flex gap-3 mb-4 items-center flex-wrap">
{% include 'components/article_card.html' with article=article %} {% 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 %} {% 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>
<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 %} {% for article in more_articles %}
{% include 'components/article_card.html' with article=article %} {% include 'components/article_card.html' with article=article %}
{% endfor %} {% endfor %}
</section> </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 %}

View File

@@ -1,8 +1,31 @@
{% load core_tags %} {% load core_tags wagtailimages_tags %}
<article> <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">
<a href="{{ article.url }}">{{ article.title }}</a> <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">
<p>{{ article.summary|truncatewords:20 }}</p> {% if article.hero_image %}
{% image article.hero_image fill-600x400 class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all duration-500" %}
{% else %}
<div class="w-full h-full min-h-[200px] flex items-center justify-center bg-zinc-900">
<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 %} {% for tag in article.tags.all %}
<span class="{{ tag|get_tag_css }}">{{ tag.name }}</span> <span class="text-xs font-mono font-bold px-2 py-1 {{ tag|get_tag_css }}">{{ tag.name }}</span>
{% endfor %} {% 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>

View File

@@ -1,27 +1,43 @@
{% if request.consent.requires_prompt %} {% if request.consent.requires_prompt %}
<div id="cookie-banner"> <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">
<form method="post" action="{% url 'consent' %}"> <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">
<div class="flex-1">
<p class="font-mono text-sm text-zinc-600 dark:text-zinc-400">
We use cookies to improve your experience.
{% if site_settings and site_settings.privacy_policy_page %}
<a href="{{ site_settings.privacy_policy_page.url }}" class="text-brand-cyan hover:underline">Privacy Policy</a>
{% endif %}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<form method="post" action="{% url 'consent' %}" class="inline">
{% csrf_token %} {% csrf_token %}
<button type="submit" name="accept_all" value="1">Accept all</button> <button type="submit" name="reject_all" value="1"
<button type="submit" name="reject_all" value="1">Reject all</button> 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>
</form> </form>
<details> <form method="post" action="{% url 'consent' %}" class="inline">
<summary>Manage preferences</summary>
<form method="post" action="{% url 'consent' %}">
{% csrf_token %} {% csrf_token %}
<label> <button type="submit" name="accept_all" value="1"
<input type="checkbox" name="analytics" value="1" /> 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>
<details class="relative">
<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>
<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">
<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 Analytics cookies
</label> </label>
<label> <label class="flex items-center gap-3 font-mono text-sm cursor-pointer">
<input type="checkbox" name="advertising" value="1" /> <input type="checkbox" name="advertising" value="1" class="accent-brand-pink" />
Advertising cookies Advertising cookies
</label> </label>
<button type="submit">Save preferences</button> <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> </form>
</div>
</details> </details>
{% if site_settings and site_settings.privacy_policy_page %} </div>
<a href="{{ site_settings.privacy_policy_page.url }}">Privacy Policy</a> </div>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@@ -1,8 +1,42 @@
{% load core_tags %} {% load core_tags %}
<footer> {% get_nav_items "footer" as footer_nav_items %}
{% get_legal_pages as legal_pages %} {% get_social_links as social_links %}
{% include 'components/newsletter_form.html' with source='footer' label='Newsletter' %} <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">
{% for page in legal_pages %} <div class="max-w-7xl mx-auto px-6 grid grid-cols-1 md:grid-cols-4 gap-8">
<a href="{{ page.url }}">{{ page.title }}</a> <div class="md:col-span-2">
<a href="/" class="font-display font-bold text-2xl tracking-tight mb-4 inline-block">{{ site_settings.site_name|default:"NO HYPE AI" }}</a>
<p class="text-zinc-500 font-mono text-sm max-w-sm mx-auto md:mx-0">
{{ 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 %} {% 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>&copy; {% 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>

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 0 0 4.5 4.5H18a3.75 3.75 0 0 0 1.332-7.257 3 3 0 0 0-3.758-3.848 5.25 5.25 0 0 0-10.233 2.33A4.502 4.502 0 0 0 2.25 15Z" /></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 0 0 .75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 0 0-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0 1 12 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 0 1-.673-.38m0 0A2.18 2.18 0 0 1 3 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 0 1 3.413-.387m7.5 0V5.25A2.25 2.25 0 0 0 13.5 3h-3a2.25 2.25 0 0 0-2.25 2.25v.894m7.5 0a48.667 48.667 0 0 0-7.5 0M12 12.75h.008v.008H12v-.008Z" /></svg>

After

Width:  |  Height:  |  Size: 783 B

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" /></svg>

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V2.75a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282m0 0h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m10.598-9.75H14.25M5.904 18.5c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 0 1-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 9.953 4.167 9.5 5 9.5h1.053c.472 0 .745.556.5.96a8.958 8.958 0 0 0-1.302 4.665c0 1.194.232 2.333.654 3.375Z" /></svg>

After

Width:  |  Height:  |  Size: 919 B

View File

@@ -0,0 +1 @@
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-8.9a.75.75 0 0 0-.53-1.1H13.5l-1.5 3-1.5-3H4.06a.75.75 0 0 0-.53 1.1l4.72 8.9-4.72 8.9a.75.75 0 0 0 .53 1.1H10.5l1.5-3 1.5 3h6.44a.75.75 0 0 0 .53-1.1l-4.72-8.9Z" /></svg>

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -1,7 +1,64 @@
<nav> {% load static core_tags %}
<a href="/">Home</a> {% get_nav_items "header" as header_items %}
<a href="/articles/">Articles</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="/about/">About</a> <div class="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<button type="button" data-theme-toggle>Toggle theme</button> <!-- Logo -->
{% include 'components/newsletter_form.html' with source='nav' label='Get updates' %} <a href="/" class="group flex items-center gap-2">
<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>

View File

@@ -1,11 +1,12 @@
<form method="post" action="/newsletter/subscribe/" data-newsletter-form> <form method="post" action="/newsletter/subscribe/" data-newsletter-form class="space-y-3" id="newsletter">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="source" value="{{ source|default:'unknown' }}" /> <input type="hidden" name="source" value="{{ source|default:'unknown' }}" />
<label> <input type="email" name="email" required placeholder="dev@example.com"
<span>{{ label|default:"Newsletter" }}</span> class="w-full bg-transparent border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-brand-dark dark:text-brand-light font-mono text-sm focus:outline-none focus:border-brand-pink transition-colors" />
<input type="email" name="email" required /> <input type="text" name="honeypot" hidden />
</label> <button type="submit"
<input type="text" name="honeypot" style="display:none" /> 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">
<button type="submit">Subscribe</button> {{ label|default:"Subscribe" }}
<p data-newsletter-message aria-live="polite"></p> </button>
<p data-newsletter-message aria-live="polite" class="font-mono text-xs text-brand-cyan min-h-[1rem]"></p>
</form> </form>

View File

@@ -1,7 +1,18 @@
{% 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>
<p>Last updated: {{ page.last_updated|date:'F Y' }}</p> <!-- Page Header -->
{{ page.body|richtext }} <div class="py-8 md:py-12 border-b border-zinc-200 dark:border-zinc-800 mb-12">
<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 %}

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@
"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"
} }
}, },
@@ -93,6 +94,33 @@
"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",

View File

@@ -7,6 +7,7 @@
"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"
} }
} }

View File

@@ -1,3 +1,40 @@
@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;
}

View File

@@ -1,10 +1,35 @@
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'],
}, },
plugins: [] 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')],
}; };