diff --git a/core/tests/test_v3_registry.py b/core/tests/test_v3_registry.py index bc742eb92..ab3cda7f9 100644 --- a/core/tests/test_v3_registry.py +++ b/core/tests/test_v3_registry.py @@ -23,6 +23,14 @@ def _get_v3_view_classes() -> set[type]: return {view_class for _, view_class in iter_v3_views()} +def _get_v3_view_classes_with_template() -> set[type]: + return { + view_class + for view_class in _get_v3_view_classes() + if getattr(view_class, "v3_template_name", None) + } + + @pytest.fixture(scope="session") def v3_view_classes(): return sorted(_get_v3_view_classes(), key=lambda c: c.__name__) @@ -35,13 +43,17 @@ def test_v3_views_discovered(v3_view_classes): @pytest.mark.parametrize( "view_class", - _get_v3_view_classes(), + _get_v3_view_classes_with_template(), ids=lambda c: c.__name__, ) def test_v3_template_exists(view_class): - """Every V3 view must point to a `v3_template_name` that Django can load.""" - template = getattr(view_class, "v3_template_name", None) - assert template, f"{view_class.__name__}: no v3_template_name set" + """Every V3 view that declares a template must point to a real one. + + Views that opt out of v3 rendering (`v3_template_name = None`, e.g. + EntryModerationDetailView) are excluded from this check but still + surface in `iter_v3_views()` so the V3 Demo registry can list them. + """ + template = view_class.v3_template_name try: get_template(template) except TemplateDoesNotExist: diff --git a/news/views.py b/news/views.py index 23c51c552..7a9faf6c9 100644 --- a/news/views.py +++ b/news/views.py @@ -11,7 +11,7 @@ from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden from django.shortcuts import redirect, get_object_or_404 from django.template.defaultfilters import date as datefilter -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.functional import cached_property from django.utils.http import url_has_allowed_host_and_scheme from django.utils.timezone import localtime, now @@ -29,6 +29,7 @@ from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadData from core.mixins import V3Mixin +from users.profile_cards import user_profile_card from .acl import can_approve from .constants import NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION from .forms import BlogPostForm, EntryForm, LinkForm, NewsForm, PollForm, VideoForm @@ -223,9 +224,19 @@ def test_func(self): return can_approve(self.request.user) -class EntryDetailView(DetailView): +class EntryDetailView(V3Mixin, DetailView): model = Entry template_name = "news/detail.html" + v3_template_name = "news/v3/detail.html" + + CATEGORY_LABELS = {"blogpost": "blog"} + AUTHOR_PREFETCH = ("author__badges", "author__maintainers") + + def get_queryset(self): + qs = super().get_queryset() + if getattr(self, "_v3_active", False): + qs = qs.select_related("author").prefetch_related(*self.AUTHOR_PREFETCH) + return qs def get_object(self, *args, **kwargs): # Published news are available to anyone, @@ -235,11 +246,64 @@ def get_object(self, *args, **kwargs): raise Http404() return result + def get_v3_context_data(self, **kwargs): + self.object = self.get_object() + entry = self.object + next_entry = ( + Entry.objects.published() + .select_related("author") + .prefetch_related(*self.AUTHOR_PREFETCH) + .filter(publish_at__gt=entry.publish_at, deleted_at__isnull=True) + .exclude(pk=entry.pk) + .order_by("publish_at", "pk") + .first() + ) + # TODO: once Entry has a relation to libraries, scope related + # posts to those linked to the libraries referenced by this + # entry. Falls back to "any other published post" until that + # relation exists. + related_qs = ( + Entry.objects.published() + .select_related("author") + .prefetch_related(*self.AUTHOR_PREFETCH) + .filter(deleted_at__isnull=True) + .exclude(pk=entry.pk) + ) + if next_entry is not None: + related_qs = related_qs.exclude(pk=next_entry.pk) + return { + "post_author": user_profile_card(entry.author), + "post_tag": self.CATEGORY_LABELS.get(entry.tag, entry.tag), + "next_post_items": ( + [self._post_card_item(next_entry)] if next_entry else [] + ), + "related_posts": [ + self._post_card_item(e) + for e in related_qs.order_by("-publish_at", "-pk")[:3] + ], + } + + @classmethod + def _post_card_item(cls, entry): + return { + "title": entry.title, + "description": entry.summary or "", + "url": reverse("news-detail", args=[entry.slug]), + "date": entry.publish_at, + "category": cls.CATEGORY_LABELS.get(entry.tag, entry.tag).capitalize(), + "author": user_profile_card(entry.author), + } + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) next_url = self.request.GET.get("next") if url_has_allowed_host_and_scheme(next_url, allowed_hosts=None): context["next_url"] = next_url + context["user_can_approve"] = self.object.can_approve(self.request.user) + context["user_can_edit"] = self.object.can_edit(self.request.user) + context["user_can_delete"] = self.object.can_delete(self.request.user) + if getattr(self, "_v3_active", False): + return context context["next"] = get_published_or_none(self.object.get_next_by_publish_at) context["prev"] = get_published_or_none(self.object.get_previous_by_publish_at) if self.object.tag: @@ -252,13 +316,11 @@ def get_context_data(self, **kwargs): context["prev_in_category"] = get_published_or_none( partial(self.object.get_previous_by_publish_at, **category_kwarg) ) - context["user_can_approve"] = self.object.can_approve(self.request.user) - context["user_can_edit"] = self.object.can_edit(self.request.user) - context["user_can_delete"] = self.object.can_delete(self.request.user) return context -class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): ... +class EntryModerationDetailView(LoginRequiredMixin, EntryDetailView): + v3_template_name = None class EntryModerationMagicApproveView(View): diff --git a/static/css/v3/post-detail.css b/static/css/v3/post-detail.css new file mode 100644 index 000000000..00b31231e --- /dev/null +++ b/static/css/v3/post-detail.css @@ -0,0 +1,305 @@ +/* ========================================================================== + Post Detail — single-post page layout. + Layout: content-centered column with sibling sections below (Next Post, + Related Posts). + ========================================================================== */ + +.post-detail-page { + display: flex; + flex-direction: column; + gap: var(--space-xl); + max-width: 696px; + margin: 0 auto; + padding: var(--space-xl) var(--space-large); + font-family: var(--font-sans); + color: var(--color-text-primary); +} + +.post-detail { + display: flex; + flex-direction: column; + gap: var(--space-xl); +} + +.post-detail__deleted-notice { + padding: var(--space-default) var(--space-large); + border: 1px solid var(--color-stroke-error); + border-radius: var(--border-radius-m); + background: var(--color-surface-error-weak); + color: var(--color-text-error); + font-family: var(--font-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-tight); +} + +.post-detail__admin-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-default); + align-items: center; + justify-content: flex-start; +} + +.post-detail__approve-form { + margin: 0; +} + +.post-detail__pending-badge { + font-family: var(--font-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--color-text-error); +} + +.post-detail__figure { + margin: 0; +} + +.post-detail__image { + display: block; + width: 100%; + height: auto; + border-radius: var(--border-radius-l); +} + +.post-detail__body { + display: flex; + flex-direction: column; + gap: var(--space-large); + font-size: var(--font-size-base); + line-height: var(--line-height-loose); + letter-spacing: var(--letter-spacing-tight); + color: var(--color-text-secondary); + padding-top: var(--space-xl); + border-top: 1px solid var(--color-stroke-weak); +} + +/* Reset the site-wide `p { @apply py-5 }` from frontend/styles.css + so paragraph rhythm comes from the body's flex `gap`, not from + each

contributing its own 1.25rem of top/bottom padding. */ +.post-detail__body p { + padding-top: 0; + padding-bottom: 0; +} + +.post-detail__body a { + color: var(--color-text-link-accent); + text-decoration: underline; +} + +.post-detail__external-url a { + color: var(--color-text-secondary); + text-decoration: underline; + text-underline-position: from-font; +} + +.post-detail__body img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius-m); +} + +.post-detail__body pre, +.post-detail__body code { + font-family: var(--font-code); + font-size: var(--font-size-small); +} + +.post-detail__body pre { + padding: var(--space-medium); + border-radius: var(--border-radius-m); + background: var(--color-surface-mid); + overflow-x: auto; +} + +.post-detail__body ul, +.post-detail__body ol { + padding-left: var(--space-large); + list-style-position: outside; +} + +.post-detail__body ul { + list-style-type: disc; +} + +.post-detail__body ol { + list-style-type: decimal; +} + +.post-detail__body ul ul { + list-style-type: circle; +} + +.post-detail__body ol ol, +.post-detail__body ul ol { + list-style-type: lower-alpha; +} + +.post-detail__body h1, +.post-detail__body h2, +.post-detail__body h3, +.post-detail__body h4, +.post-detail__body h5, +.post-detail__body h6 { + color: var(--color-text-primary); + font-family: var(--font-display); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); +} + +.post-detail__body h1 { font-size: var(--font-size-xl); } +.post-detail__body h2 { font-size: var(--font-size-large); } +.post-detail__body h3 { font-size: var(--font-size-medium); } +.post-detail__body h4, +.post-detail__body h5, +.post-detail__body h6 { font-size: var(--font-size-base); } + +.post-detail__body b, +.post-detail__body strong { + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); +} + +.post-detail__next, +.post-detail__related { + display: flex; + flex-direction: column; +} + +/* The first section after the article gets an extra 32px of top spacing + on top of the page-level 32px gap, totaling 64px (Figma spec). The + subsequent section (next -> related) keeps the plain 32px gap. */ +.post-detail-page > .post-detail + section { + margin-top: var(--space-xl); +} + +/* Inner card-group defaults to max-width: 458px, a tinted background, + a border, and vertical padding; none of those belong in the + post-detail context. */ +.post-detail__next .card-group, +.post-detail__related .card-group { + max-width: none; + background: none; + border: none; + padding-top: 0; + padding-bottom: 0; +} + +.post-detail__next .card-group__heading, +.post-detail__related .card-group__heading { + padding-left: 0; +} + +/* Specificity bumped by chaining .card-group--card to beat the + `.card-group--card .card-group__list { padding: 0 var(--space-card) }` + rule in card-group.css. */ +.post-detail__next .card-group--card .card-group__list, +.post-detail__related .card-group--card .card-group__list { + padding: 0; + gap: 0; +} + +/* `.card-group--card .card-group__item` (card-group.css) gives each + item its own background and border-radius. The per-item radius is + what creates the apparent "indented hairline" between stacked cards + (rounded corners notch into each other). Strip both so the post-card + inside controls the visual. */ +.post-detail__next .card-group--card .card-group__item, +.post-detail__related .card-group--card .card-group__item { + background: none; + border-radius: 0; +} + +.post-detail__next .post-card, +.post-detail__related .post-card { + background: var(--color-surface-weak); + border: 1px solid var(--color-stroke-weak); +} + +.post-detail-page .post-detail__next .post-card__title-block, +.post-detail-page .post-detail__related .post-card__title-block { + gap: var(--space-s); +} + +.post-detail-page .user-profile__header { + row-gap: var(--space-default); +} + +/* Collapse adjacent borders so stacked cards share a single hairline. */ +.post-detail__next .card-group__item:not(:first-child) .post-card, +.post-detail__related .card-group__item:not(:first-child) .post-card { + border-top: none; +} + +/* Stacked post-cards: square by default; only the outer two corners of + the stack are rounded. Single-item lists (e.g. Next Post) end up + fully rounded since :first-child and :last-child both match. */ +.post-detail__next .post-card, +.post-detail__related .post-card { + border-radius: 0; +} + +.post-detail__next .card-group__item:first-child .post-card, +.post-detail__related .card-group__item:first-child .post-card { + border-top-left-radius: var(--border-radius-xl); + border-top-right-radius: var(--border-radius-xl); +} + +.post-detail__next .card-group__item:last-child .post-card, +.post-detail__related .card-group__item:last-child .post-card { + border-bottom-left-radius: var(--border-radius-xl); + border-bottom-right-radius: var(--border-radius-xl); +} + +/* Related posts render in a single column across all breakpoints. */ +.post-detail__related .card-group__list { + grid-template-columns: 1fr; +} + +/* ---------- Dark mode ---------- */ + +/* card-group.css applies a grey background in dark mode via + `html.dark .card-group--default`. That selector is more specific than + our base override, so we re-strip it here for the post-detail + sections. */ +html.dark .post-detail__next .card-group, +html.dark .post-detail__related .card-group { + background: none; +} + +/* ---------- Breakpoints ---------- */ + +/* Tablet and desktop: add vertical breathing room */ +@media (min-width: 768px) { + .post-detail-page { + padding-top: var(--space-xxl); + padding-bottom: var(--space-xxl); + padding-left: 0; + padding-right: 0; + } +} + +/* Tablet only */ +@media (min-width: 768px) and (max-width: 1279px) { + .post-header { + gap: var(--space-large); + } +} + +/* Mobile */ +@media (max-width: 767px) { + .post-header { + gap: var(--space-large); + } + .post-detail-page { + gap: var(--space-large); + padding: var(--space-medium); + padding-bottom: 64px; + } + /* Total gap between article body and the first sibling section is + 64px on mobile (Figma spec). Subtracts the page-level flex gap so + gap + margin-top = 64. */ + .post-detail-page > .post-detail + section { + margin-top: calc(64px - var(--space-large)); + } +} diff --git a/static/css/v3/post-header.css b/static/css/v3/post-header.css new file mode 100644 index 000000000..b4c475344 --- /dev/null +++ b/static/css/v3/post-header.css @@ -0,0 +1,43 @@ +/* ========================================================================== + Post Header — title, meta row, and author block at the top of a post. + ========================================================================== */ + +.post-header { + display: flex; + flex-direction: column; + gap: var(--space-medium); +} + +.post-header__title { + margin: 0; + font-family: var(--font-display); + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-tight); + letter-spacing: var(--letter-spacing-tight); + color: var(--color-text-primary); +} + +/* Meta row spacing is a Figma-spec value (6px) with no exact token + equivalent (--space-s = 4px, --space-default = 8px desktop). */ +.post-header__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); +} + +/* Inline separator dot. Width/height are glyph metrics, not layout + spacing, so they don't map to a space-* token. */ +.post-header__meta > :not(:first-child)::before { + content: ""; + display: inline-block; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--color-text-secondary); + margin-right: 6px; + vertical-align: middle; +} diff --git a/templates/news/v3/detail.html b/templates/news/v3/detail.html new file mode 100644 index 000000000..d2c084297 --- /dev/null +++ b/templates/news/v3/detail.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% load static %} +{% load wagtailmarkdown %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block extra_head %} + {{ block.super }} + + +{% endblock %} + +{% block content %} +

+
+ {% if object.deleted_at %} +
+ Entry deleted on {{ object.deleted_at|date:"m/d/Y" }}{% if object.deleted_by %} by {{ object.deleted_by.display_name }}{% endif %}. +
+ {% endif %} + {% if user_can_approve or user_can_edit or user_can_delete %} +
+ {% if not object.is_approved and not object.deleted_at %} + {% if user_can_approve %} +
+ {% csrf_token %} + {% if next_url %}{% endif %} + {% include "v3/includes/_button.html" with label="Approve" type="submit" style="green" %} +
+ {% else %} + Pending Moderation + {% endif %} + {% endif %} + {% if user_can_edit and not object.deleted_at %} + {% url 'news-update' object.slug as edit_url %} + {% include "v3/includes/_button.html" with url=edit_url label="Edit" style="secondary" %} + {% endif %} + {% if user_can_delete and not object.deleted_at %} + {% url 'news-delete' object.slug as delete_url %} + {% include "v3/includes/_button.html" with url=delete_url label="Delete" style="error" %} + {% endif %} +
+ {% endif %} + {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=post_tag author=post_author %} + + {% if object.image %} +
+ +
+ {% endif %} + +
+ {% if object.external_url %} +

+ {{ object.external_url }} +

+ {% endif %} + {% with body=object.content|default:object.visible_content %} + {% if body %}{{ body|markdown|urlize }}{% endif %} + {% endwith %} +
+
+ + {% if next_post_items %} +
+ {% include "v3/includes/_post_card.html" with heading="Next Post" items=next_post_items variant="card" theme="default" %} +
+ {% endif %} + + {% if related_posts %} +
+ {% include "v3/includes/_post_card.html" with heading="Related Posts" items=related_posts variant="card" theme="default" %} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/v3/includes/_post_card.html b/templates/v3/includes/_post_card.html index 9b56770bd..8a82b91c8 100644 --- a/templates/v3/includes/_post_card.html +++ b/templates/v3/includes/_post_card.html @@ -40,7 +40,7 @@

{{ item.title }}

{% endif %} {% if item.date or item.category or item.tag %}
- {% if item.date %}{% endif %} + {% if item.date %}{% endif %} {% if item.category %}{{ item.category }}{% endif %} {% if item.tag %}#{{ item.tag }}{% endif %}
diff --git a/templates/v3/includes/_post_header.html b/templates/v3/includes/_post_header.html new file mode 100644 index 000000000..5b937a3d7 --- /dev/null +++ b/templates/v3/includes/_post_header.html @@ -0,0 +1,25 @@ +{% comment %} + V3 Post Header – header block for a single post: title, meta row + (publish date + optional category tag), and an author profile block. + + Variables: + title (string, required) + publish_date (datetime, required) + tag (string, optional) — short label rendered as plain text + author (dict, optional) — passed verbatim to _user_profile.html + + Usage: + {% include "v3/includes/_post_header.html" with title=object.title publish_date=object.publish_at tag=object.tag author=post_author %} +{% endcomment %} +
+

{{ title }}

+
+ + {% if tag %} + {{ tag|capfirst }} + {% endif %} +
+ {% if author %} + {% include "v3/includes/_user_profile.html" with author=author only %} + {% endif %} +
diff --git a/users/profile_cards.py b/users/profile_cards.py new file mode 100644 index 000000000..7cd9b7d44 --- /dev/null +++ b/users/profile_cards.py @@ -0,0 +1,25 @@ +from django.conf import settings + + +def user_profile_card(user): + """Build the dict consumed by v3/includes/_user_profile.html. + + Truthiness checks (rather than .exists() / .filter() with kwargs) + so prefetch_related caches are reused. Callers should prefetch + "badges" and "maintainers" on the user. + """ + is_maintainer = bool(user.maintainers.all()) + badges = list(user.badges.all()) + badge = badges[0] if badges else None + badge_url = ( + f"{settings.STATIC_URL}img/v3/badges/badge-{badge.name}.png" + if badge and badge.name + else "" + ) + return { + "name": user.display_name, + "profile_url": user.github_profile_url or "", + "role": "Maintainer" if is_maintainer else "Contributor", + "avatar_url": user.get_avatar_url(), + "badge_url": badge_url, + }