Blog cards
Articles as cards: the cover art, the title, the author and the time it takes to read.
Featured long read with a wide cover
blog-cards-01The most read of the guides that take seven minutes or more, featured on a wide drawn cover beside its shelf, summary, author, revision date and reads, with two more from its shelf.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { formatNumber } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { ProductArt } from "@/components/ui/product-art"
import { UserCell } from "@/components/ui/user-cell"
import { GUIDES_HREF, LONG_READ_MINUTES, featuredGuide, type Guide } from "./blog-cards-01.data"
/** The featured guide: its cover, its story, and the next most read on its shelf. */
function Featured({ guide, more }: { guide: Guide; more: Guide[] }) {
const articleId = React.useId()
const moreId = React.useId()
// The ring is drawn inside the article when its link is focused: outside,
// the sheet's rounded clip would cut it.
return (
<article
aria-labelledby={articleId}
className="group/guide relative grid has-[a[data-guide]:focus-visible]:focus-outline-inset lg:grid-cols-12"
>
<ProductArt
name={guide.title}
glyph={guide.glyph}
className="aspect-video rounded-none lg:col-span-7 [&>svg]:h-auto [&>svg]:w-[40%]"
/>
<div className="flex min-w-0 flex-col gap-4 p-5 md:p-8 lg:col-span-5">
<p className="type-eyebrow tabular-nums">
{guide.shelf} · {guide.minutes} min read
</p>
<h3 id={articleId} className="type-display text-xl text-balance">
{/* The stretched link: its box covers the article, cover
included, so the whole story is one target. */}
<Link
data-guide=""
href={guide.href}
className="outline-none after:absolute after:inset-0 group-hover/guide:underline group-hover/guide:underline-offset-4"
>
{guide.title}
</Link>
</h3>
<p className="text-sm text-pretty text-muted-foreground">{guide.summary}</p>
<UserCell name={guide.author.name} description={guide.author.role} src={guide.author.avatarUrl} />
<dl className="flex flex-wrap gap-x-8 gap-y-2 text-xs">
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Updated</dt>
<dd className="font-medium tabular-nums">{guide.updated}</dd>
</div>
<div className="flex flex-col gap-0.5">
<dt className="text-muted-foreground">Reads, last 90 days</dt>
<dd className="font-medium tabular-nums">{formatNumber(guide.views)}</dd>
</div>
</dl>
<span
aria-hidden="true"
className="inline-flex items-center gap-1 text-sm font-medium text-brand"
>
Read the guide
<ArrowRightIcon className="size-4 transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/guide:translate-x-0.5" />
</span>
{more.length > 0 ? (
// Above the stretched link, so each of these is its own target.
<div className="relative z-10 mt-auto flex flex-col gap-2 border-t border-border pt-4">
<p id={moreId} className="type-eyebrow">
More in {guide.shelf}
</p>
<ul aria-labelledby={moreId} className="flex flex-col gap-1.5">
{more.map((row) => (
<li key={row.id} className="flex items-baseline justify-between gap-3 text-sm">
<Link
href={row.href}
className="min-w-0 rounded-sm font-medium hover:underline hover:underline-offset-4 focus-ring"
>
{row.title}
</Link>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{row.minutes} min</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</article>
)
}
/**
* One guide, featured: a wide cover on the left of the sheet and the story
* on the right — its shelf and reading time, the title, the summary, who
* signed it and how often it has been read — with the next two guides from
* the same shelf under it. The whole article is one link, and one stop in
* the tab order; the two below it are links of their own.
*
* A server component: it reads its guides through `./blog-cards-01.data`
* and draws the cover with `ProductArt`, never a photograph.
*/
export function BlogCards01() {
const titleId = React.useId()
const featured = featuredGuide()
return (
<section
data-section="blog-cards-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>In depth</h2>
</CardTitle>
<CardDescription>The most read of the guides that take {LONG_READ_MINUTES} minutes or more</CardDescription>
<CardAction>
<Link href={GUIDES_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
All guides
</Link>
</CardAction>
</CardHeader>
{featured ? (
<CardContent className="p-0">
<Featured guide={featured.guide} more={featured.more} />
</CardContent>
) : (
<CardContent>
<EmptyState
size="sm"
title="No guides yet"
description="The help centre's most read long read is featured here once it has one."
/>
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default BlogCards01/**
* What `blog-cards-01` reads: the guide worth setting time aside for — the
* most read of the help centre's long reads, the ones that take
* `LONG_READ_MINUTES` or more — and the next two most read on its shelf.
* Every guide is a `db.articles` row read through `guides-support`, which
* signs it and draws its cover the way every guide band does.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { GUIDES_HREF, guides, type Guide } from "@/lib/dashboards/support/guides"
export { GUIDES_HREF }
export type { Guide }
/** A long read: this many minutes or more. */
export const LONG_READ_MINUTES = 7
/** How many more guides from the featured one's shelf the band lists. */
export const MORE_FROM_SHELF = 2
/**
* The most read long read, and the next most read on its shelf. A help
* centre with no long read features its most read guide of any length, and
* one with no guide at all features nothing: null.
*/
export function featuredGuide(): { guide: Guide; more: Guide[] } | null {
const all = guides()
const guide = all.find((row) => row.minutes >= LONG_READ_MINUTES) ?? all[0]
if (!guide) return null
const more = all.filter((row) => row.shelf === guide.shelf && row.id !== guide.id).slice(0, MORE_FROM_SHELF)
return { guide, more }
}npx shadcn@latest add @vibra/blog-cards-01Recently updated, as rows
blog-cards-02The five guides revised last as a list of rows: a small drawn cover, the shelf and the revision date, the title and summary, and on the right who signed it and its reading time.
import * as React from "react"
import Link from "next/link"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { ProductArt } from "@/components/ui/product-art"
import { UserCell } from "@/components/ui/user-cell"
import { GUIDES_HREF, recentGuides } from "./blog-cards-02.data"
/**
* The guides revised last, as a list of rows: a small cover, the shelf and
* the day it changed, the title and its summary, and on the right who
* signed it and how long it takes. Each row is one link. On a phone the
* summary folds away and the byline moves under the title, face and all.
*
* A server component: it reads its guides through `./blog-cards-02.data`
* and draws each cover with `ProductArt`, never a photograph.
*/
export function BlogCards02() {
const titleId = React.useId()
const rows = recentGuides()
return (
<section
data-section="blog-cards-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Recently updated</h2>
</CardTitle>
<CardDescription>The guides the Support team revised last, newest first</CardDescription>
<CardAction>
<Link href={GUIDES_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
All guides
</Link>
</CardAction>
</CardHeader>
{rows.length > 0 ? (
<CardContent className="p-0">
<ol className="divide-y divide-border">
{rows.map((guide) => (
<li
key={guide.id}
className="group/row relative flex items-start gap-4 p-4 transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/50 has-[a:focus-visible]:focus-outline-inset sm:items-center md:px-5"
>
<ProductArt name={guide.title} glyph={guide.glyph} className="w-14 shrink-0 rounded-md sm:w-16" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<p className="text-xs text-muted-foreground tabular-nums">
{guide.shelf} · Updated <time dateTime={guide.updatedAt.toISOString()}>{guide.updated}</time>
</p>
<h3 className="text-sm font-semibold text-pretty">
{/* The stretched link: the whole row is one target. */}
<Link
href={guide.href}
className="outline-none after:absolute after:inset-0 group-hover/row:underline group-hover/row:underline-offset-4"
>
{guide.title}
</Link>
</h3>
<p className="hidden text-sm text-muted-foreground sm:line-clamp-1">{guide.summary}</p>
{/* The byline under the title on a phone, where there is no column for it. */}
<p className="flex items-center gap-1.5 pt-0.5 text-xs text-muted-foreground md:hidden">
<Avatar className="size-5" aria-hidden="true">
<AvatarImage src={guide.author.avatarUrl} alt="" />
<AvatarFallback className="text-avatar">{getInitials(guide.author.name)}</AvatarFallback>
</Avatar>
<span className="truncate">
<span className="font-medium text-foreground">{guide.author.name}</span>
<span className="tabular-nums"> · {guide.minutes} min read</span>
</span>
</p>
</div>
<div className="hidden w-64 shrink-0 items-center justify-between gap-4 md:flex">
<UserCell size="sm" name={guide.author.name} description={guide.author.role} src={guide.author.avatarUrl} />
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{guide.minutes} min read</span>
</div>
</li>
))}
</ol>
</CardContent>
) : (
<CardContent>
<EmptyState size="sm" title="No guides yet" description="The help centre’s guides are listed here, the latest revised first." />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default BlogCards02/**
* What `blog-cards-02` reads: the guides the Support team revised most
* recently, newest first. Every guide is a `db.articles` row read through
* `guides-support`, which signs it and draws its cover the way every guide
* band does; the date is the article's own `updatedAt`, said in UTC.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { GUIDES_HREF, guides, type Guide } from "@/lib/dashboards/support/guides"
export { GUIDES_HREF }
export type { Guide }
/** How many rows the list shows. */
export const RECENT_COUNT = 5
/** The `count` guides revised last, newest first; a tie goes to the title. */
export function recentGuides(count = RECENT_COUNT): Guide[] {
return guides()
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() || a.title.localeCompare(b.title))
.slice(0, count)
}npx shadcn@latest add @vibra/blog-cards-02Compact grid, one guide per shelf
blog-cards-03The most read guide on each of the help centre's six shelves as compact cards: a short drawn cover, the shelf, the title, the author and the reading time, divided by hairlines.
import * as React from "react"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { ProductArt } from "@/components/ui/product-art"
import { UserCell } from "@/components/ui/user-cell"
import { GUIDES_HREF, shelfPicks } from "./blog-cards-03.data"
/**
* A shelf-by-shelf grid of compact cards: the most read guide on each of the
* help centre's shelves, each a short cover, its shelf, its title, who
* signed it and how long it takes. The cells share hairlines rather than
* each drawing a box, so six guides read as one sheet; one across on a
* phone, two from `sm`, three from `lg`. Each cell is one link.
*
* A server component: it reads its guides through `./blog-cards-03.data`
* and draws each cover with `ProductArt`, never a photograph.
*/
export function BlogCards03() {
const titleId = React.useId()
const picks = shelfPicks()
return (
<section
data-section="blog-cards-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Guides by shelf</h2>
</CardTitle>
<CardDescription>The most read guide on every shelf of the help centre</CardDescription>
<CardAction>
<Link href={GUIDES_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
All guides
</Link>
</CardAction>
</CardHeader>
{picks.length > 0 ? (
<CardContent className="p-0">
{/* The grid's ground shows through the one-pixel gaps as the rules between cells. */}
<ul className="grid gap-px bg-border sm:grid-cols-2 lg:grid-cols-3">
{picks.map((guide) => (
<li
key={guide.id}
className="group/cell relative flex items-start gap-3 bg-card p-4 has-[a:focus-visible]:focus-outline-inset sm:flex-col sm:items-stretch"
>
{/* A square thumbnail beside the words on a phone, a short cover over them from `sm`. */}
<ProductArt
name={guide.title}
glyph={guide.glyph}
className="w-16 shrink-0 rounded-md sm:aspect-[5/2] sm:w-full sm:[&>svg]:h-auto sm:[&>svg]:w-[29%]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">{guide.shelf}</p>
<h3 className="text-sm font-semibold text-pretty">
{/* The stretched link: the whole cell is one target. */}
<Link
href={guide.href}
className="outline-none after:absolute after:inset-0 group-hover/cell:underline group-hover/cell:underline-offset-4"
>
{guide.title}
</Link>
</h3>
</div>
<div className="mt-auto flex items-center justify-between gap-3">
<UserCell size="sm" name={guide.author.name} description={guide.author.role} src={guide.author.avatarUrl} />
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{guide.minutes} min read</span>
</div>
</div>
</li>
))}
</ul>
</CardContent>
) : (
<CardContent>
<EmptyState size="sm" title="No guides yet" description="One guide from each shelf of the help centre shows here once the shelves have some." />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default BlogCards03/**
* What `blog-cards-03` reads: one guide from every shelf of the help centre
* — the shelf's most read — in the order the entity lists its shelves. Every
* guide is a `db.articles` row read through `guides-support`, which signs it
* and draws its cover the way every guide band does.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { GUIDES_HREF, guides, type Guide } from "@/lib/dashboards/support/guides"
import { ARTICLE_CATEGORIES } from "@/lib/sample-data"
export { GUIDES_HREF }
export type { Guide }
/** The most read guide on each shelf that has one, shelf by shelf. */
export function shelfPicks(): Guide[] {
const all = guides()
return ARTICLE_CATEGORIES.flatMap((shelf) => all.find((guide) => guide.shelf === shelf) ?? [])
}npx shadcn@latest add @vibra/blog-cards-03Quick reads with topics and a save toggle
blog-cards-04The four most read guides under five minutes as cards with a drawn cover, their topics as tags, the author, and a save toggle, with a status line that counts what is saved.
import * as React from "react"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { ProductArt } from "@/components/ui/product-art"
import { QuickReadGrid } from "./blog-cards-04.client"
import { GUIDES_HREF, QUICK_READ_MINUTES, quickReads } from "./blog-cards-04.data"
/**
* The quick reads: the most read guides that take under five minutes, as
* cards with a cover, the shelf and reading time, the title and summary, the
* topics each covers as tags, who signed it, and a save toggle. Under the
* grid, a status line counts what has been saved.
*
* A server component: it reads its guides through `./blog-cards-04.data`,
* draws each cover with `ProductArt` here — so the drawings never ship to
* the browser — and hands the island plain rows and the covers.
*/
export function BlogCards04() {
const titleId = React.useId()
const reads = quickReads()
const minutes = reads.reduce((total, read) => total + read.minutes, 0)
const covers = Object.fromEntries(
reads.map((read) => [read.id, <ProductArt key={read.id} name={read.title} glyph={read.glyph} className="aspect-[5/2] rounded-md [&>svg]:h-auto [&>svg]:w-[29%] sm:aspect-[4/3] sm:[&>svg]:w-[54%]" />])
)
return (
<section
data-section="blog-cards-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Quick reads</h2>
</CardTitle>
<CardDescription className="tabular-nums">
The most read guides under {QUICK_READ_MINUTES} minutes
{reads.length > 0 ? ` · ${minutes} minutes for all ${reads.length}` : null}
</CardDescription>
<CardAction>
<Link href={GUIDES_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
All guides
</Link>
</CardAction>
</CardHeader>
{reads.length > 0 ? (
<QuickReadGrid reads={reads} covers={covers} />
) : (
<CardContent>
<EmptyState size="sm" title="No quick reads yet" description={`Guides that take under ${QUICK_READ_MINUTES} minutes show here, the most read first.`} />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default BlogCards04"use client"
import * as React from "react"
import Link from "next/link"
import { BookmarkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { CardContent, CardFooter } from "@/components/ui/card"
import { TagList } from "@/components/ui/tag-list"
import { UserCell } from "@/components/ui/user-cell"
import type { QuickRead } from "./blog-cards-04.data"
export type QuickReadGridProps = {
reads: QuickRead[]
/** Each guide's cover, drawn on the server and keyed by the guide's id. */
covers: Record<string, React.ReactNode>
/** Guides saved already, by id. */
defaultSaved?: string[]
}
const guidesWord = (count: number) => `${count} ${count === 1 ? "guide" : "guides"}`
/**
* The quick reads as a grid of cards, each with its topics as tags and a
* save toggle, and under the grid a status line that says what the last
* press did and how many guides are saved. The toggle is a pressed button —
* the bookmark fills and the button takes the accent's tint — and keeps one
* name, "Save" and the title, whichever way it is set. Saving is the
* island's own state: wire it to your reading list to keep it.
*/
export function QuickReadGrid({ reads, covers, defaultSaved = [] }: QuickReadGridProps) {
const [saved, setSaved] = React.useState<ReadonlySet<string>>(() => new Set(defaultSaved))
const [message, setMessage] = React.useState<string | null>(null)
function toggle(read: QuickRead) {
const next = new Set(saved)
const on = !next.has(read.id)
if (on) next.add(read.id)
else next.delete(read.id)
setSaved(next)
setMessage(
`${on ? "Saved" : "Removed"} “${read.title}”. ${guidesWord(next.size)} saved to read later.`
)
}
return (
<>
<CardContent className="p-0">
{/* The grid's ground shows through the one-pixel gaps as the rules between cards. */}
<ul className="grid gap-px bg-border sm:grid-cols-2 xl:grid-cols-4">
{reads.map((read) => {
const isSaved = saved.has(read.id)
return (
<li
key={read.id}
data-saved={isSaved || undefined}
className="group/read relative flex flex-col gap-3 bg-card p-4 has-[a:focus-visible]:focus-outline-inset"
>
{covers[read.id]}
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground tabular-nums">
{read.shelf} · {read.minutes} min read
</p>
<h3 className="text-sm font-semibold text-pretty">
{/* The stretched link: the card is one target, the save toggle apart. */}
<Link
href={read.href}
className="outline-none after:absolute after:inset-0 group-hover/read:underline group-hover/read:underline-offset-4"
>
{read.title}
</Link>
</h3>
<p className="line-clamp-2 text-sm text-pretty text-muted-foreground">{read.summary}</p>
</div>
{read.topics.length > 0 ? (
<TagList tags={read.topics} size="sm" role="group" aria-label={`Topics of ${read.title}`} />
) : null}
<div className="mt-auto flex items-center justify-between gap-3 pt-1">
<UserCell size="sm" name={read.author.name} description={read.author.role} src={read.author.avatarUrl} />
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-pressed={isSaved}
aria-label={`Save ${read.title}`}
onClick={() => toggle(read)}
// Above the stretched link, so a press saves rather than opens the guide.
className="relative z-10 shrink-0 text-muted-foreground aria-pressed:bg-brand-muted aria-pressed:text-foreground"
>
<BookmarkIcon aria-hidden="true" className={isSaved ? "fill-current" : undefined} />
</Button>
</div>
</li>
)
})}
</ul>
</CardContent>
<CardFooter className="text-xs text-muted-foreground">
{/* Always mounted, so each press is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="text-pretty tabular-nums">
{message ?? (saved.size > 0 ? `${guidesWord(saved.size)} saved to read later.` : "Save a guide to find it again later.")}
</p>
</CardFooter>
</>
)
}/**
* What `blog-cards-04` reads: the quick reads — the most read guides that
* take under `QUICK_READ_MINUTES` — each with the topics it covers. Every
* guide is a `db.articles` row read through `guides-support`, which signs it
* and draws its cover the way every guide band does.
*
* The entity records no tags, so a guide's topics are a rule over its own
* words: the terms of `TOPICS` it uses, the ones in its title first, then
* its summary, then its body, three at most.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { GUIDES_HREF, guides, type Guide, type GuideAuthor } from "@/lib/dashboards/support/guides"
export { GUIDES_HREF }
/** A quick read takes fewer minutes than this. */
export const QUICK_READ_MINUTES = 5
/** How many quick reads the band shows. */
export const QUICK_READ_COUNT = 4
/** How many topics a guide wears. */
export const MAX_TOPICS = 3
/** The topics a guide can be tagged with, and the words that put it under each. */
export const TOPICS: readonly (readonly [string, RegExp])[] = [
["Workspaces", /\bworkspaces?\b/i],
["Teammates", /\b(teammates?|members?|invitations?|invite)\b/i],
["Roles", /\broles?\b/i],
["Regions", /\bregions?\b/i],
["Invoices", /\binvoices?\b/i],
["Payment cards", /\bcards?\b/i],
["Plans", /\bplans?\b/i],
["Two-factor", /\btwo-factor\b/i],
["Passkeys", /\bpasskeys?\b/i],
["Recovery codes", /\brecovery codes?\b/i],
["Single sign-on", /\bsingle sign-on\b/i],
["Audit log", /\baudit log\b/i],
["API keys", /\bapi keys?\b/i],
["Webhooks", /\bwebhooks?\b/i],
["Imports", /\bimport(s|er|ed)?\b/i],
["Exports", /\bexports?\b/i],
["Notifications", /\bnotifications?\b/i],
]
/** A quick read as the band draws it: plain values an island can take as props. */
export type QuickRead = Pick<Guide, "id" | "title" | "href" | "shelf" | "summary" | "minutes" | "glyph"> & {
author: GuideAuthor
topics: string[]
}
/** The topics of `TOPICS` a guide's words use: title first, then summary, then body. */
export function topicsOf(guide: Pick<Guide, "title" | "summary" | "body">, max = MAX_TOPICS): string[] {
const found: string[] = []
for (const text of [guide.title, guide.summary, guide.body]) {
for (const [topic, words] of TOPICS) if (words.test(text) && !found.includes(topic)) found.push(topic)
}
return found.slice(0, max)
}
/** The `count` most read guides that take under `QUICK_READ_MINUTES`, with their topics. */
export function quickReads(count = QUICK_READ_COUNT): QuickRead[] {
return guides()
.filter((guide) => guide.minutes < QUICK_READ_MINUTES)
.slice(0, count)
.map(({ id, title, href, shelf, summary, minutes, glyph, author, body }) => ({
id,
title,
href,
shelf,
summary,
minutes,
glyph,
author,
topics: topicsOf({ title, summary, body }),
}))
}npx shadcn@latest add @vibra/blog-cards-04