Story 2296: V3 Post Detail page#2373
Conversation
a8e4ca1 to
08602ac
Compare
Adds the V3 post detail page at /v3/news/entry/<slug>/, used by all Entry types (blog, news, link, video, poll). - New V3PostDetailView with real next/related queries. - Reusable _post_header include (title, meta, author block). - Body escapes user content and falls back to summary when content is empty.
08602ac to
60eac1a
Compare
Source content hard-wrapped at ~80 chars was rendering a forced <br> on every soft newline. The new filter splits on blank lines into <p> tags and joins single newlines with spaces, so prose flows to the container width. Autolinks URLs and escapes HTML; preserves XSS protection.
- Body typography per Figma: text-secondary, line-height 135%, letter-spacing, paragraph spacing via flex gap. - 1px separator line above body with 32px on each side. - 64px gap between body and first sibling section; 32px between Next Post and Related Post. - Card-group + post-card refinement: page-flush borders, outer-corner rounding only, transparent card-group background in light and dark, subtle per-card background. - Post-header bullet now a CSS-drawn dot. - Spacing fix: Figma "xxl" (32px) maps to --space-xl.
Author role derives from user.maintainers (Maintainer when the
user maintains any LibraryVersion, Contributor otherwise).
Badge picks the user's first Badge and points at the static
convention static/img/v3/badges/badge-{name}.png. Both lookups
are batched via prefetch_related on the entry, next, and
related querysets.
_post_card_item now exposes entry.summary as description so the cards have content for the description slot the PostFeed work in flight will render. No template changes here; the dict is plumbed end-to-end on our side and waits for the PostFeed include to read it.
Link entries render their external URL as the link text, which screen readers spell out character by character. The new aria-label gives a meaningful announcement (post title plus new-tab cue) while keeping the URL visible for sighted users. Focus-visible styling is already covered globally by v3-style-overrides.css; body text contrast meets WCAG AA in both light and dark modes.
Mobile (<768px): - post-header gap raised to --space-large. - 64px between article body and the first sibling section. - 64px page padding-bottom (gap below the last related card). Tablet (768-1279px): - post-header gap raised to --space-large.
The post-header date now reads m/d/Y per design. Next/related card dates still render d/m/Y since that format lives in the shared _post_card.html include, which is also used by the homepage, learn page, and component demo. Updating those is a separate change.
Refs PR boostorg#2373 review feedback. --font-mono is not a defined design token; the correct identifier for monospace text is --font-code.
Refs PR boostorg#2373 review feedback.
3fbe4dd to
251defa
Compare
| @register.filter | ||
| def text_paragraphs(value): | ||
| """Render hard-wrapped plain text as autolinked paragraphs. | ||
|
|
||
| Blank lines become paragraph breaks; single newlines inside a | ||
| paragraph collapse to spaces so source hard-wrapped at ~80 chars | ||
| flows naturally to the container width. | ||
| """ | ||
| if not value: | ||
| return "" | ||
| paragraphs = [] | ||
| for chunk in _PARAGRAPH_SPLIT.split(str(value)): | ||
| text = " ".join(line.strip() for line in chunk.splitlines() if line.strip()) | ||
| if text: | ||
| paragraphs.append(f"<p>{urlize(text, autoescape=True)}</p>") | ||
| return mark_safe("\n".join(paragraphs)) |
There was a problem hiding this comment.
Hmm I think forcing single newlines into spaces might break the formatting of some lists in the old posts, please see this example:
- Original template: http://localhost:8000/news/entry/boost-c-libraries-and-fiscal-sponsorship/
- V3 template: http://localhost:8000/v3/news/entry/boost-c-libraries-and-fiscal-sponsorship/
(Notice the numbered list and * list, the single newline on Thank you, was intentional too)
Is it possible to refine the strategy here a bit more to not affect lists? 🤔
| .prefetch_related(*self.AUTHOR_PREFETCH) | ||
| .filter(publish_at__gt=entry.publish_at) | ||
| .exclude(pk=entry.pk) | ||
| .order_by("publish_at") |
There was a problem hiding this comment.
Can we add a secondary order rule here, just in case the publish_at is a tie?
| if not entry.can_view(self.request.user): | ||
| raise Http404() |
There was a problem hiding this comment.
| @staticmethod | ||
| def _author_card(author): | ||
| # Truthiness checks (rather than .exists() / .first() with kwargs) | ||
| # so prefetch_related caches are reused — see queryset setup below. | ||
| is_maintainer = bool(author.maintainers.all()) | ||
| badges = list(author.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": author.display_name, | ||
| "profile_url": author.github_profile_url or "", | ||
| "role": "Maintainer" if is_maintainer else "Contributor", | ||
| "avatar_url": author.get_avatar_url(), | ||
| "badge_url": badge_url, | ||
| } |
There was a problem hiding this comment.
Just a thought – might be worth pulling _author_card out of this view into a shared helper? This template can be used on numerous components/pages (testimonial card, post card, user card, etc). Not blocking – we can leave it to a follow-up too!
There was a problem hiding this comment.
oh totally missed that, but I agree with Julia!
| return context | ||
|
|
||
|
|
||
| class V3PostDetailView(V3Mixin, TemplateView): |
There was a problem hiding this comment.
Just a thought – if we swap TemplateView for EntryDetailView as the base, would that let us drop the v3/news/entry/<slug:slug>/ route entirely? It would be so nice if it's possible to reuse the existing path!
Entry.objects.published() filters published=True but not the soft-delete flag, so deleted posts could surface in the V3 post detail Next Post and Related Posts sections. Filter deleted_at__isnull=True on both querysets.


Issue: #2296
Summary & Context
Implements Story 2296: Webpage UI: Posts Detail. Adds the V3 post detail page at
/v3/news/entry/<slug>/, mounted on the polymorphicEntrymodel so blog posts, news, links, videos, and polls all render through the same layout. Built on the V3 registry pattern from #2308 (V3Mixin+config/v3_urls.py). Reuses existing V3 design-system includes (_user_profile,_post_card->_card_group) and adds one new reusable include (_post_header).Figma link:
Link to components/page:
Changes
Routing (V3 registry pattern, per #2308)
v3/news/entry/<slug:slug>/namedv3-news-detailinconfig/v3_urls.py.View (
news/views.py)V3PostDetailViewisV3Mixin + TemplateView. Returns 404 when thev3waffle flag is off; renders the V3 template when it's on. Verified bycore.tests.test_v3_registry.Entry.can_view.publish_atis after the current's; excludes self.select_related("author")andprefetch_related("author__badges", "author__maintainers")to avoid N+1._author_cardand_post_card_itembuild the dict shape the includes expect.TAG_LABELS = {"blogpost": "blog"}so the meta tag renders a friendlier label.user.maintainers(reverse ofLibraryVersion.maintainers): "Maintainer" if the user maintains any library version, otherwise "Contributor".Badge, mapped via the static-image conventionstatic/img/v3/badges/badge-{name}.png. Renders nothing when the user has no badges (current state of the DB seed).Body rendering
text_paragraphsinnews/templatetags/news_tags.py. Authors hard-wrap source at ~80 chars; the previous|linebreakswas forcing<br>on every soft newline. The new filter splits on blank lines into<p>tags and joins single newlines with spaces. Autolinks URLs and escapes HTML, preserving XSS protection.object.content; falls back toobject.visible_content(AI summary) for Link entries with no content of their own.object.external_urlsurfaces as a link for Link entries.New reusable include (
templates/v3/includes/_post_header.html)title,publish_date, optionaltag, optionalauthor._user_profile.htmlfor the author block.CSS
static/css/v3/post-header.cssandstatic/css/v3/post-detail.css.Body rendering is plain text + autolinking, not markdown. Ticket criterion 4 calls for markdown with Boostlook 2.0 styling. Boostlook 2.0 isn't ready, so this PR leaves markdown for a follow-up. Bold/italic/code-fence syntax in source will appear literally until then.
Entry.objects.published()covers all subtypes. Next/Related can surface BlogPost, Link, News, Video, or Poll entries. If the editorial intent is "blog detail only shows blog-style siblings," that's a follow-up filter.Role rule is a chosen default. "Maintains at least one
LibraryVersion-> Maintainer" is a reasonable starting rule; if the team wants something stricter (current versions only, primary library, etc.) the predicate is one line in_author_card.Badges depend on data. The
Badgemodel has onlyname/display_name(no image field). The view assemblesstatic/img/v3/badges/badge-{name}.pngfromBadge.namebased on the existing static-asset convention used in mock data. Wiring is live; nothing renders until badges are seeded and assigned.Description on Next/Related cards is plumbed but not yet rendered.
_post_card_itemexposesentry.summaryasdescriptionon each card dict, but_post_card.html(the in-flight PostFeed work) doesn't read the field yet. Once that include lands, the cards will start showing the AI summary automatically with no further changes on this side.The next/related card date format needs to be updated in
_post_card.html. The post-header now usesm/d/Yper design, but the cards still renderd/m/Ybecause the format lives in the shared_post_card.htmlinclude.Screenshots
Self-review Checklist
Frontend