Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
"content": {
"type": "richtext"
},
"sidebar": {
"type": "component",
"component": "cards.cta-card",
"repeatable": true
},
"sections": {
"type": "dynamiczone",
"components": [
Expand Down
5 changes: 5 additions & 0 deletions apps/strapi/src/api/blog/content-types/blog/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
"type": "relation",
"relation": "oneToOne",
"target": "api::blog-post.blog-post"
},
"sidebar": {
"type": "component",
"component": "cards.cta-card",
"repeatable": true
}
}
}
37 changes: 37 additions & 0 deletions apps/strapi/src/components/cards/cta-card.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"collectionName": "components_cards_cta_cards",
"info": {
"displayName": "CTA Card",
"icon": "cursor",
"description": "Reusable call-to-action card: title, optional image, description and one link. Usable in any dynamic zone."
},
"options": {},
"attributes": {
"title": {
"type": "string",
"required": true,
"maxLength": 60
},
"description": {
"type": "text",
"maxLength": 160
},
"image": {
"type": "component",
"component": "utilities.basic-image",
"repeatable": false
},
"ctaLink": {
"type": "component",
"component": "utilities.link",
"repeatable": false,
"required": true
},
"buttonStyle": {
"type": "enumeration",
"enum": ["primary", "secondary"],
"default": "secondary",
"required": true
}
}
}
27 changes: 27 additions & 0 deletions apps/strapi/types/generated/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,32 @@ export interface CardsContentCard extends Struct.ComponentSchema {
}
}

export interface CardsCtaCard extends Struct.ComponentSchema {
collectionName: "components_cards_cta_cards"
info: {
description: "Reusable call-to-action card: title, optional image, description and one link. Usable in any dynamic zone."
displayName: "CTA Card"
icon: "cursor"
}
attributes: {
buttonStyle: Schema.Attribute.Enumeration<["primary", "secondary"]> &
Schema.Attribute.Required &
Schema.Attribute.DefaultTo<"secondary">
ctaLink: Schema.Attribute.Component<"utilities.link", false> &
Schema.Attribute.Required
description: Schema.Attribute.Text &
Schema.Attribute.SetMinMaxLength<{
maxLength: 160
}>
image: Schema.Attribute.Component<"utilities.basic-image", false>
title: Schema.Attribute.String &
Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{
maxLength: 60
}>
}
}

export interface CardsFeatureCard extends Struct.ComponentSchema {
collectionName: "components_cards_feature_card"
info: {
Expand Down Expand Up @@ -1707,6 +1733,7 @@ declare module "@strapi/strapi" {
"blog.resource-cta": BlogResourceCta
"cards.case-study-card": CardsCaseStudyCard
"cards.content-card": CardsContentCard
"cards.cta-card": CardsCtaCard
"cards.feature-card": CardsFeatureCard
"cms.field-entry": CmsFieldEntry
"elements.brand-logo-grid-item": ElementsBrandLogoGridItem
Expand Down
2 changes: 2 additions & 0 deletions apps/strapi/types/generated/contentTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ export interface ApiBlogPostBlogPost extends Struct.CollectionTypeSchema {
]
>
seo: Schema.Attribute.Component<"shared.seo", false>
sidebar: Schema.Attribute.Component<"cards.cta-card", true>
slug: Schema.Attribute.UID<"title"> & Schema.Attribute.Required
tags: Schema.Attribute.Relation<"manyToMany", "api::post-tag.post-tag">
title: Schema.Attribute.String & Schema.Attribute.Required
Expand Down Expand Up @@ -659,6 +660,7 @@ export interface ApiBlogBlog extends Struct.SingleTypeSchema {
navigation: Schema.Attribute.Component<"blog.navigation", false>
newsletter: Schema.Attribute.Component<"forms.newsletter", false>
publishedAt: Schema.Attribute.DateTime
sidebar: Schema.Attribute.Component<"cards.cta-card", true>
updatedAt: Schema.Attribute.DateTime
updatedBy: Schema.Attribute.Relation<"oneToOne", "admin::user"> &
Schema.Attribute.Private
Expand Down
69 changes: 69 additions & 0 deletions apps/ui/src/components/blog/BlogSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Data } from "@repo/strapi-types"

import { StrapiCtaCard } from "@/components/page-builder/components/cards/StrapiCtaCard"
import { cn } from "@/lib/styles"

type CtaCard = Data.Component<"cards.cta-card">

interface BlogSidebarProps {
/** Cards set on the post itself. When non-empty these replace the default. */
readonly postCards?: CtaCard[] | null
/** Blog-wide default cards from the Blog single type. */
readonly defaultCards?: CtaCard[] | null
/**
* `column` is the fixed-width sticky rail beside the article.
* `inline` flows the cards into the article width once there is no room
* beside it — without this they keep the rail's narrow width and look broken.
*/
readonly variant?: "column" | "inline"
readonly className?: string
/** Applied to the card wrapper — used to make the column stick on scroll. */
readonly innerClassName?: string
}

/**
* Blog post side column.
*
* Cards are configured blog-wide on the Blog single type and can be overridden
* per post. The override is all-or-nothing rather than a merge: merge order
* would be ambiguous and invisible to the editor.
*
* Renders nothing when neither source has cards, so posts without a sidebar
* keep the original centered layout.
*/
export function BlogSidebar({
postCards,
defaultCards,
variant = "column",
className,
innerClassName,
}: BlogSidebarProps) {
const cards = postCards?.length ? postCards : (defaultCards ?? [])

if (cards.length === 0) {
return null
}

return (
<aside
data-slot="blog-sidebar"
data-variant={variant}
aria-label="Related resources"
className={className}
>
<div
className={cn(
"gap-4",
variant === "column"
? "flex w-70 flex-col"
: "grid grid-cols-1 sm:grid-cols-2",
innerClassName
)}
>
{cards.map((card) => (
<StrapiCtaCard key={card.id} component={card} />
))}
</div>
</aside>
)
}
117 changes: 88 additions & 29 deletions apps/ui/src/components/layouts/StrapiBlogPostView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@/components/blog/BlogAuthorBanner"
import { BlogAutoRelatedPosts } from "@/components/blog/BlogAutoRelatedPosts"
import { BlogContent } from "@/components/blog/BlogContent"
import { BlogSidebar } from "@/components/blog/BlogSidebar"
import { BlogSocialShare } from "@/components/blog/BlogSocialShare"
import { Container } from "@/components/elementary/Container"
import { StrapiSeoStructuredDataFromSeo } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData"
Expand All @@ -17,8 +18,9 @@ import { extractHeadings, type BlogPost } from "@/lib/blog-utils"
import { getEnvVar } from "@/lib/env-vars"
import { routing } from "@/lib/navigation"
import { SECTION_SPACING } from "@/lib/section-spacing"
import { fetchBlogPost } from "@/lib/strapi-api/content/server"
import { fetchBlog, fetchBlogPost } from "@/lib/strapi-api/content/server"
import { buildBlogPostingJsonLd } from "@/lib/structured-data/blog-posting"
import { cn } from "@/lib/styles"

import { BlogNavbar } from "../blog/BlogNavbar"
import { BlogPostHeader } from "../blog/BlogPostHeader"
Expand All @@ -43,12 +45,22 @@ export function StrapiBlogPostView({ params }: Props) {
setRequestLocale(locale)

const response = use(fetchBlogPost(slug, locale))
// Blog-wide sidebar defaults, overridden per post when the post sets its own.
const blogSettings = use(fetchBlog(locale))

const post = response?.data as BlogPost | null | undefined
if (!post) {
notFound()
}

// Same resolution the sidebar itself applies: a post overrides the blog-wide
// default outright. Needed here too, because the surrounding layout changes
// when a side column is present.
const sidebarCards = post.sidebar?.length
? post.sidebar
: (blogSettings?.data?.sidebar ?? [])
const hasSidebar = sidebarCards.length > 0

const sections = post.sections
const author = post.author as BlogAuthor | null
const content = post.content
Expand Down Expand Up @@ -100,36 +112,83 @@ export function StrapiBlogPostView({ params }: Props) {
{content && (
<section className="py-8 lg:py-16">
<div className="relative">
{headings.length > 0 && (
<aside className="absolute top-0 left-0 hidden h-full xl:block">
<BlogTableOfContents headings={headings} />
</aside>
)}

<aside className="absolute top-0 left-[calc(50%+27rem+1rem)] hidden h-full xl:block">
<div className="sticky top-28 pb-24">
<BlogSocialShare
url={postUrl}
title={post.title ?? ""}
variant="sticky"
/>
{/* With a side column the ToC becomes a real column in the
row, so all four tracks line up on the site grid. Without
one it keeps its original full-bleed absolute position. */}
<div
className={cn(
"mx-auto flex justify-center",
hasSidebar && "w-full max-w-312 gap-8 px-4 xl:px-0"
)}
>
{headings.length > 0 && (
<aside
className={cn(
"hidden h-full",
hasSidebar
? "w-52 shrink-0 xl:block"
: "absolute top-0 left-0 xl:block"
)}
>
<BlogTableOfContents headings={headings} />
</aside>
)}

<div className="relative w-full max-w-216 min-w-0">
<Container variant="condensed">
<article data-slot="blog-article">
<BlogContent>{content}</BlogContent>
</article>

<BlogSocialShare
url={postUrl}
title={post.title ?? ""}
variant="row"
className="xl:hidden"
/>

{/* No room beside the article below xl, so the cards
flow into the article width rather than vanishing
or keeping the rail's narrow column. */}
<BlogSidebar
variant="inline"
className="mt-8 xl:hidden"
postCards={post.sidebar}
defaultCards={blogSettings?.data?.sidebar}
/>

<BlogAuthorBanner author={author} />
</Container>

{/* Anchored to the article, not the viewport, so it holds
its place whether or not a side column is present. */}
<aside className="absolute top-0 left-full ml-4 hidden h-full xl:block">
<div className="sticky top-28 pb-24">
<BlogSocialShare
url={postUrl}
title={post.title ?? ""}
variant="sticky"
/>
</div>
</aside>
</div>
</aside>

<Container variant="condensed">
<article data-slot="blog-article">
<BlogContent>{content}</BlogContent>
</article>

<BlogSocialShare
url={postUrl}
title={post.title ?? ""}
variant="row"
className="xl:hidden"
/>

<BlogAuthorBanner author={author} />
</Container>
{/* ml clears the share rail, which is absolutely positioned
just outside the article and would otherwise sit flush
against the cards. */}
{/* A sticky element can only travel through space its
container has left over. The share rail is short so it
always has room; a tall card stack does not — hence the
viewport cap, which keeps the column pinned (scrolling
internally if needed) instead of drifting off-screen.
Bottom padding is kept small for the same reason. */}
<BlogSidebar
className="hidden shrink-0 xl:ml-20 xl:block"
innerClassName="sticky top-28 max-h-[calc(100vh-8rem)] overflow-y-auto pb-8"
postCards={post.sidebar}
defaultCards={blogSettings?.data?.sidebar}
/>
</div>
</div>
</section>
)}
Expand Down
Loading
Loading