Skip to content

Story 2296: V3 Post Detail page#2373

Open
julioest wants to merge 13 commits intoboostorg:developfrom
julioest:feat/v3-post-detail-2296
Open

Story 2296: V3 Post Detail page#2373
julioest wants to merge 13 commits intoboostorg:developfrom
julioest:feat/v3-post-detail-2296

Conversation

@julioest
Copy link
Copy Markdown
Collaborator

@julioest julioest commented Apr 29, 2026

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 polymorphic Entry model 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)

  • New URL v3/news/entry/<slug:slug>/ named v3-news-detail in config/v3_urls.py.

View (news/views.py)

  • V3PostDetailView is V3Mixin + TemplateView. Returns 404 when the v3 waffle flag is off; renders the V3 template when it's on. Verified by core.tests.test_v3_registry.
  • Permission gated via Entry.can_view.
  • Real DB queries for next + related (no mock data):
    • Next: oldest published Entry whose publish_at is after the current's; excludes self.
    • Related: 3 most recent published entries; excludes self and the next post.
    • All querysets use select_related("author") and prefetch_related("author__badges", "author__maintainers") to avoid N+1.
  • Helpers _author_card and _post_card_item build the dict shape the includes expect.
  • TAG_LABELS = {"blogpost": "blog"} so the meta tag renders a friendlier label.
  • Author role is derived from user.maintainers (reverse of LibraryVersion.maintainers): "Maintainer" if the user maintains any library version, otherwise "Contributor".
  • Author badge is the user's first Badge, mapped via the static-image convention static/img/v3/badges/badge-{name}.png. Renders nothing when the user has no badges (current state of the DB seed).

Body rendering

  • Custom filter text_paragraphs in news/templatetags/news_tags.py. Authors hard-wrap source at ~80 chars; the previous |linebreaks was 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.
  • Body prefers object.content; falls back to object.visible_content (AI summary) for Link entries with no content of their own.
  • object.external_url surfaces as a link for Link entries.
  • Author email is no longer used as a fallback for missing display name.

New reusable include (templates/v3/includes/_post_header.html)

  • Inputs: title, publish_date, optional tag, optional author.
  • Internally uses _user_profile.html for the author block.

CSS

  • New static/css/v3/post-header.css and static/css/v3/post-detail.css.
  • Card-group and post-card refinement: page-flush borders, outer-corner rounding only, transparent card-group background in light and dark, subtle per-card background.

‼️ Risks & Considerations ‼️

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 Badge model has only name/display_name (no image field). The view assembles static/img/v3/badges/badge-{name}.png from Badge.name based 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_item exposes entry.summary as description on 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 uses m/d/Y per design, but the cards still render d/m/Y because the format lives in the shared _post_card.html include.

Screenshots

2296-post-detail-desktop 2296-post-detail-mobile

Self-review Checklist

  • Tag at least one team member from each team to review this PR
  • Link this PR to the related GitHub Project ticket

Frontend

  • UI implementation matches Figma design
  • Tested in light and dark mode
  • Responsive / mobile verified
  • Accessibility checked (keyboard navigation, etc.)
  • Ensure design tokens are used for colors, spacing, typography, etc. – No hardcoded values
  • Test without JavaScript (if applicable)
  • No console errors or warnings

@julioest julioest force-pushed the feat/v3-post-detail-2296 branch from a8e4ca1 to 08602ac Compare April 29, 2026 15:53
@julioest julioest marked this pull request as ready for review April 29, 2026 17:23
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.
@julioest julioest force-pushed the feat/v3-post-detail-2296 branch from 08602ac to 60eac1a Compare April 29, 2026 23:35
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.
@herzog0 herzog0 linked an issue Apr 30, 2026 that may be closed by this pull request
Copy link
Copy Markdown
Collaborator

@herzog0 herzog0 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All looking pretty good, just a couple of nits and a larger x-padding in mobile that it's smaller in Figma. Pre-approving!

Image

Comment thread static/css/v3/post-detail.css Outdated
Comment thread templates/news/v3/detail.html Outdated
julioest added a commit to julioest/website-v2 that referenced this pull request May 5, 2026
Refs PR boostorg#2373 review feedback. --font-mono is not a defined
design token; the correct identifier for monospace text is
--font-code.
julioest added a commit to julioest/website-v2 that referenced this pull request May 5, 2026
@julioest julioest force-pushed the feat/v3-post-detail-2296 branch from 3fbe4dd to 251defa Compare May 5, 2026 14:28
Copy link
Copy Markdown
Collaborator

@julhoang julhoang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @julioest ! The UI is looking really great! As for the BE, I've left some suggestions for us to consider, and the main change request is for removing the deleted posts from the Next Post/Related Posts queries. 🙏

Comment on lines +12 to +27
@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))
Copy link
Copy Markdown
Collaborator

@julhoang julhoang May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I think forcing single newlines into spaces might break the formatting of some lists in the old posts, please see this example:

Image

(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? 🤔

Comment thread news/views.py
Comment thread news/views.py
.prefetch_related(*self.AUTHOR_PREFETCH)
.filter(publish_at__gt=entry.publish_at)
.exclude(pk=entry.pk)
.order_by("publish_at")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a secondary order rule here, just in case the publish_at is a tie?

Comment thread news/views.py
Comment on lines +359 to +360
if not entry.can_view(self.request.user):
raise Http404()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the new UI, it seems like we've lost the admin-only(?) warning that flags deleted entries. Non-admins still can't access deleted entries, so there's no security issue – but I think we should bring the warning back so admins know at a glance that an entry has been deleted. 🤔

cc @henryajisegiri

Image

Comment thread news/views.py
Comment on lines +392 to +410
@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,
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh totally missed that, but I agree with Julia!

Comment thread news/views.py
return context


class V3PostDetailView(V3Mixin, TemplateView):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

julioest added 2 commits May 5, 2026 16:38
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Webpage UI: Posts Detail

3 participants