mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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're looking for doesn'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}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user