fix(landing): align author-page UI with the site shell and link bylines to it (#5655)

* fix(landing): align author-page UI with the site shell and link bylines to it

Rebuilds ContentAuthorPage/ContentAuthorLoading to match the standard
page-shell pattern used by ContentIndexPage/ContentPostPage (max-w-[1460px]
header, BackLink, framed post-list rows with hover states) instead of the
previous bare max-w-[900px] container with a 2-column image grid. Adds a
styled not-found fallback matching the comparisons/integrations pattern.

The byline author Link in ContentPostPage now points at the internal
author page (/{basePath}/authors/{id}) instead of the author's external
X/GitHub profile, giving the author page a real navigation entry point for
the first time - previously it was only reachable via the sitemap.

Also fixes both author-page route files to match on any of a post's
authors (post.authors.some(a => a.id === id)) instead of only the primary
author (post.author.id === id), so posts with co-authors show up on every
listed author's page, consistent with the sitemap's own author enumeration.

* fix(landing): address review findings on author-page UI rebuild

- Not-found fallback no longer renders a nested <main id='main-content'>
  - blog/layout.tsx and library/layout.tsx already provide that landmark,
  so the fallback now uses <section> like the rest of the component.
- Byline author link now encodeURIComponent()s the author id before
  using it as a route segment.
- Loading skeleton's post-list wrapper now matches the real page's
  mx-auto max-w-[1460px] shell (was mx-20/max-lg:mx-8, which stretches
  past the header on wide viewports).

* fix(landing): make author Person.image an absolute URL in JSON-LD

Same class of bug as the earlier article-image fix: author.avatarUrl is a
site-relative path, so Person.image was emitting an invalid relative URL
for crawlers. Prefixes with SITE_URL, matching buildArticleJsonLd's
existing pattern.

* fix(landing): align author not-found fallback width with the site convention

max-w-[1446px]/px-12 was copied from comparisons/not-found.tsx, which turns
out to be the outlier - models/not-found.tsx and integrations/not-found.tsx
both use max-w-[1460px]/px-20, matching the rest of the site's shell and
this component's own success-state header.

* fix(landing): encode author id consistently in every author URL builder

The byline link encoded the author id, but buildAuthorMetadata's canonical
URL, buildAuthorGraphJsonLd's Person.url/BreadcrumbList item, and
sitemap.ts's buildAuthorPages URL still interpolated the raw id - a
mismatch could point sitemap/canonical/structured-data URLs at a
different route than the actual page. All four now encodeURIComponent()
consistently.
This commit is contained in:
Waleed
2026-07-13 18:02:07 -07:00
committed by GitHub
parent 8b9f5934af
commit 5dc8d8f097
7 changed files with 149 additions and 71 deletions
@@ -11,18 +11,20 @@ export async function generateMetadata({
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
return buildAuthorMetadata(id, posts[0]?.author)
const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id))
const author = posts[0]?.authors.find((a) => a.id === id)
return buildAuthorMetadata(id, author)
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id))
const author = posts[0]?.authors.find((a) => a.id === id)
return (
<ContentAuthorPage
basePath={BLOG_SECTION.basePath}
sectionName={BLOG_SECTION.name}
authorName={author?.name}
authorAvatarUrl={author?.avatarUrl}
posts={posts}
@@ -5,22 +5,34 @@ const AUTHOR_POST_SKELETON_COUNT = 4
/** Shared loading skeleton for a content section's author-profile route. */
export function ContentAuthorLoading() {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<div className='mb-6 flex items-center gap-3'>
<Skeleton className='size-[40px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[32px] w-[160px] rounded-[4px] bg-[var(--surface-hover)]' />
<section className='bg-[var(--bg)]'>
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<Skeleton className='mb-6 h-[16px] w-[100px] rounded-md bg-[var(--surface-hover)]' />
<div className='flex items-center gap-4'>
<Skeleton className='size-[64px] rounded-full bg-[var(--surface-hover)]' />
<Skeleton className='h-[40px] w-[240px] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => (
<div key={i} className='overflow-hidden rounded-lg border border-[var(--border)]'>
<Skeleton className='h-[160px] w-full rounded-none bg-[var(--surface-hover)]' />
<div className='p-3'>
<Skeleton className='mb-1 h-[12px] w-[80px] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[200px] rounded-[4px] bg-[var(--surface-hover)]' />
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
{Array.from({ length: AUTHOR_POST_SKELETON_COUNT }).map((_, i) => (
<div key={i}>
<div className='flex items-center gap-6 p-6'>
<Skeleton className='hidden h-[14px] w-[120px] rounded-[4px] bg-[var(--surface-hover)] md:block' />
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<Skeleton className='h-[18px] w-[70%] rounded-[4px] bg-[var(--surface-hover)]' />
<Skeleton className='h-[14px] w-[90%] rounded-[4px] bg-[var(--surface-hover)]' />
</div>
<Skeleton className='hidden h-[80px] w-[140px] rounded-[5px] bg-[var(--surface-hover)] sm:block' />
</div>
<div className='h-px w-full bg-[var(--border)]' />
</div>
</div>
))}
))}
</div>
</div>
</main>
</section>
)
}
@@ -1,11 +1,16 @@
import { ChipLink } from '@sim/emcn'
import Image from 'next/image'
import Link from 'next/link'
import type { ContentMeta } from '@/lib/content/schema'
import { BackLink } from '@/app/(landing)/components/back-link'
import { Cta } from '@/app/(landing)/components/cta/cta'
import { JsonLd } from '@/app/(landing)/components/json-ld'
interface ContentAuthorPageProps {
/** Route base path, e.g. `/blog` or `/library`. */
basePath: string
/** Section label used in the not-found fallback, e.g. "Blog" or "Library". */
sectionName: string
authorName?: string
authorAvatarUrl?: string
/** Posts already filtered down to this author. */
@@ -13,9 +18,15 @@ interface ContentAuthorPageProps {
graphJsonLd?: Record<string, unknown>
}
/** Shared author-profile layout for a content section. */
/**
* Shared author-profile layout for a content section: standard page-shell
* header (matching `ContentIndexPage`/`ContentPostPage`) with avatar + name,
* followed by the author's posts in the same framed-list card style used
* everywhere else on the site.
*/
export function ContentAuthorPage({
basePath,
sectionName,
authorName,
authorAvatarUrl,
posts,
@@ -23,54 +34,104 @@ export function ContentAuthorPage({
}: ContentAuthorPageProps) {
if (!authorName) {
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
<h1 className='text-[32px] text-[var(--text-primary)]'>Author not found</h1>
</main>
<section className='mx-auto flex min-h-[60vh] w-full max-w-[1460px] flex-col items-center justify-center gap-3 px-20 py-24 text-center max-sm:px-5 max-lg:px-8'>
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
Author not found
</h1>
<p className='text-[var(--text-muted)] text-lg'>
The author you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<ChipLink variant='primary' href={basePath} className='mt-3'>
Browse {sectionName}
</ChipLink>
</section>
)
}
return (
<main className='mx-auto max-w-[900px] px-6 py-10 sm:px-8 md:px-12'>
{graphJsonLd && <JsonLd data={graphJsonLd} />}
<div className='mb-6 flex items-center gap-3'>
{authorAvatarUrl ? (
<Image
src={authorAvatarUrl}
alt={authorName}
width={40}
height={40}
className='rounded-full'
unoptimized
/>
) : null}
<h1 className='text-[32px] text-[var(--text-primary)] leading-tight'>{authorName}</h1>
</div>
<div className='grid grid-cols-1 gap-8 sm:grid-cols-2'>
{posts.map((p) => (
<Link key={p.slug} href={`${basePath}/${p.slug}`} className='group'>
<div className='overflow-hidden rounded-lg border border-[var(--border)]'>
<>
<section className='bg-[var(--bg)]'>
{graphJsonLd && <JsonLd data={graphJsonLd} />}
<div className='mx-auto w-full max-w-[1460px] px-20 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
<div className='mb-6'>
<BackLink href={basePath} label={`Back to ${sectionName}`} />
</div>
<div className='flex items-center gap-4'>
{authorAvatarUrl ? (
<Image
src={p.ogImage}
alt={p.title}
width={600}
height={315}
className='h-[160px] w-full object-cover transition-transform group-hover:scale-[1.02]'
src={authorAvatarUrl}
alt={authorName}
width={64}
height={64}
className='rounded-full'
unoptimized
/>
<div className='p-3'>
<div className='mb-1 text-[var(--text-muted)] text-xs'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
<div className='text-[var(--text-primary)] text-sm leading-tight'>{p.title}</div>
) : null}
<h1 className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'>
{authorName}
</h1>
</div>
</div>
<div className='mt-8 h-px w-full bg-[var(--border)]' />
<div className='mx-auto w-full max-w-[1460px] px-20 max-sm:px-5 max-lg:px-8'>
<div className='border-[var(--border)] border-x'>
{posts.map((p) => (
<div key={p.slug}>
<Link
href={`${basePath}/${p.slug}`}
className='group flex items-start gap-6 p-6 transition-colors hover:bg-[var(--surface-hover)] md:items-center'
>
<span className='hidden w-[120px] shrink-0 pt-1 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:block'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<div className='flex min-w-0 flex-1 flex-col gap-1'>
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] md:hidden'>
{new Date(p.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
<h3 className='text-[var(--text-primary)] text-base leading-tight tracking-[-0.01em] lg:text-lg'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-muted)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
<div className='relative hidden h-[80px] w-[140px] shrink-0 overflow-hidden rounded-[5px] sm:block'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='140px'
className='object-cover'
unoptimized
/>
</div>
</Link>
<div className='h-px w-full bg-[var(--border)]' />
</div>
</div>
</Link>
))}
))}
</div>
</div>
<div className='-mt-px h-px w-full bg-[var(--border)]' />
</section>
<div className='mt-[120px] max-sm:mt-16 max-lg:mt-[88px]'>
<Cta />
</div>
</main>
</>
)
}
@@ -96,9 +96,8 @@ export function ContentPostPage({
</Avatar>
) : null}
<Link
href={a?.url || '#'}
target='_blank'
rel='noopener noreferrer author'
href={`${basePath}/authors/${encodeURIComponent(a?.id ?? '')}`}
rel='author'
className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em] hover:text-[var(--text-primary)]'
itemProp='author'
itemScope
@@ -11,18 +11,20 @@ export async function generateMetadata({
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
return buildAuthorMetadata(id, posts[0]?.author)
const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id))
const author = posts[0]?.authors.find((a) => a.id === id)
return buildAuthorMetadata(id, author)
}
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
const author = posts[0]?.author
const posts = (await getAllPostMeta()).filter((p) => p.authors.some((a) => a.id === id))
const author = posts[0]?.authors.find((a) => a.id === id)
return (
<ContentAuthorPage
basePath={LIBRARY_SECTION.basePath}
sectionName={LIBRARY_SECTION.name}
authorName={author?.name}
authorAvatarUrl={author?.avatarUrl}
posts={posts}
+1 -1
View File
@@ -25,7 +25,7 @@ function buildAuthorPages(posts: ContentMeta[], basePath: string): MetadataRoute
}
}
return [...authorsMap.entries()].map(([id, date]) => ({
url: `${SITE_URL}${basePath}/authors/${id}`,
url: `${SITE_URL}${basePath}/authors/${encodeURIComponent(id)}`,
lastModified: date,
}))
}
+6 -4
View File
@@ -285,7 +285,7 @@ export function buildAuthorMetadata(
author?: Author
): Metadata {
const name = author?.name ?? 'Author'
const canonical = `${SITE_URL}${section.basePath}/authors/${id}`
const canonical = `${SITE_URL}${section.basePath}/authors/${encodeURIComponent(id)}`
const description = `Read articles by ${name} on the Sim ${section.name.toLowerCase()}.`
return {
title: `${name} | Sim ${section.name}`,
@@ -318,9 +318,11 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author)
{
'@type': 'Person',
name: author.name,
url: `${SITE_URL}${section.basePath}/authors/${author.id}`,
url: `${SITE_URL}${section.basePath}/authors/${encodeURIComponent(author.id)}`,
sameAs: author.url ? [author.url] : [],
image: author.avatarUrl,
image: author.avatarUrl?.startsWith('http')
? author.avatarUrl
: author.avatarUrl && `${SITE_URL}${author.avatarUrl}`,
worksFor: {
'@type': 'Organization',
name: 'Sim',
@@ -341,7 +343,7 @@ export function buildAuthorGraphJsonLd(section: ContentSection, author: Author)
'@type': 'ListItem',
position: 3,
name: author.name,
item: `${SITE_URL}${section.basePath}/authors/${author.id}`,
item: `${SITE_URL}${section.basePath}/authors/${encodeURIComponent(author.id)}`,
},
],
},