Skip to content

Commit b83de10

Browse files
committed
feat: bluesky comments on blog posts
Per-post opt-in via blueskyPostUrl in MDX meta. When set, the post page renders a Bluesky-branded reply pill plus the underlying thread (via bluesky-comments). Posts without the field are unaffected. The standalone "About the author" colophon is folded into the byline so each post has one author block instead of two: name, social icons, date, optional cross-post note, and a far-right "Jump to comments" link. Also restores the global Footer on blog post pages, which had only ever been wired into the homepage and showcase pages.
1 parent f3fed42 commit b83de10

9 files changed

Lines changed: 367 additions & 93 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@ Before editing anything that touches styled-components APIs (`createTheme`, `The
5252
- OKLCH hue 0° is pink/magenta, not red. Warmer = higher hue toward orange. Read `logoPalette.ts` for the offset that places red at step 0.
5353
- `mix-blend-mode` and `filter` on children of `preserve-3d` elements flattens 3D. Use alpha in background colors or `color-mix` instead.
5454
- Blog posts are assembled dynamically from MDX files at build time by `utils/blog.server.ts`. No JSON index to maintain — just create the MDX file with `export const meta`.
55+
- Blog comments (Bluesky) are opt-in per post via `blueskyPostUrl` in the MDX `meta` export. No URL → no comments section. Auto-discovery is intentionally not used because Bluesky closed public access to `searchPosts`. `components/BlogComments.tsx` is a client component that imports `bluesky-comments` CSS and overrides its hashed CSS-Module selectors via `[class*='_name_']` attribute matches so overrides survive package upgrades.
5556
- `PlatonicLogo.tsx` faces must NOT use `backface-visibility: hidden`. Per-face axis-angle interpolation during morph transitions can briefly flip a face normal and cull mid-animation. `transform-style: preserve-3d` z-sorts back faces naturally. Per-face depth bias (tiny outward push along each face's normal) breaks z-degeneracy for edge-on faces without culling.
5657
- `CelebrationEffect.tsx` particles are `React.memo`'d; `onAnimationEnd` is a stable `useCallback` that reads `fwId`/`particleId` from `data-*` attributes. Don't close over IDs in per-item arrow functions — it defeats memoization on the particle list.

components/BlogComments.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
'use client';
2+
3+
import styled from 'styled-components';
4+
import { BlueskyComments } from 'bluesky-comments';
5+
import 'bluesky-comments/bluesky-comments.css';
6+
import { theme } from '../utils/theme';
7+
import rem from '../utils/rem';
8+
import { ColophonRule } from './BlogMeta';
9+
10+
const BLUESKY_BLUE = '#0285FF';
11+
const BLUESKY_BLUE_HOVER = '#006BD6';
12+
13+
function BlueskyIcon() {
14+
return (
15+
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
16+
<path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026" />
17+
</svg>
18+
);
19+
}
20+
21+
export default function BlogComments({ blueskyPostUrl }: { blueskyPostUrl: string }) {
22+
return (
23+
<Wrapper id="comments">
24+
<ColophonRule aria-hidden="true" />
25+
<ReplyButton href={blueskyPostUrl} target="_blank" rel="noopener">
26+
<BlueskyIcon />
27+
Reply on Bluesky
28+
</ReplyButton>
29+
<BlueskyComments uri={blueskyPostUrl} />
30+
</Wrapper>
31+
);
32+
}
33+
34+
const Wrapper = styled.div`
35+
margin-top: ${theme.space[16]};
36+
scroll-margin-top: ${theme.space[16]};
37+
38+
[class*='_container_'] {
39+
max-width: none;
40+
}
41+
42+
/* Hide the "Comments" heading when there's no reply list to head —
43+
the package renders it unconditionally. onEmpty() can't be used because
44+
it only fires on errors, not on empty-reply threads. */
45+
[class*='_container_']:not(:has([class*='_commentsList_'])) [class*='_commentsTitle_'] {
46+
display: none;
47+
}
48+
49+
[class*='_replyText_'] {
50+
display: none;
51+
}
52+
53+
[class*='_statsBar_'] {
54+
gap: ${theme.space[5]};
55+
}
56+
57+
[class*='_container_'] a,
58+
[class*='_container_'] a[class*='_link_'],
59+
[class*='_container_'] a[class*='_link_']:hover {
60+
text-decoration: none;
61+
font-weight: ${theme.fontWeight.medium};
62+
}
63+
64+
/* Heart (likes) → red */
65+
[class*='_icon_'][fill='pink'] {
66+
fill: ${theme.palette[0]};
67+
}
68+
[class*='_icon_'][stroke='pink'] {
69+
stroke: ${theme.palette[0]};
70+
}
71+
72+
/* Recycle (reposts) → green. fill stays "none" to preserve outline */
73+
[class*='_icon_'][stroke='green'] {
74+
stroke: ${theme.palette[7]};
75+
}
76+
77+
/* Speech bubble (replies) → blue */
78+
[class*='_icon_'][fill='#7FBADC'] {
79+
fill: ${theme.palette[12]};
80+
}
81+
[class*='_icon_'][stroke='#7FBADC'] {
82+
stroke: ${theme.palette[12]};
83+
}
84+
`;
85+
86+
const ReplyButton = styled.a`
87+
display: inline-flex;
88+
align-items: center;
89+
gap: ${rem(8)};
90+
padding: ${rem(10)} ${rem(18)};
91+
margin-bottom: ${theme.space[6]};
92+
border-radius: 9999px;
93+
background: ${BLUESKY_BLUE};
94+
color: #ffffff;
95+
font-family: inherit;
96+
font-size: ${theme.text.sm};
97+
font-weight: ${theme.fontWeight.semibold};
98+
text-decoration: none;
99+
transition: background ${theme.duration.fast};
100+
101+
&:hover,
102+
&:focus-visible {
103+
background: ${BLUESKY_BLUE_HOVER};
104+
color: #ffffff;
105+
text-decoration: none;
106+
}
107+
108+
svg {
109+
width: ${rem(16)};
110+
height: ${rem(16)};
111+
}
112+
`;

components/BlogMeta.tsx

Lines changed: 101 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,73 +2,80 @@
22

33
import styled from 'styled-components';
44
import { Github, Twitter } from '@styled-icons/fa-brands';
5-
import { Globe } from '@styled-icons/boxicons-regular';
5+
import { ChevronDown, Globe } from '@styled-icons/boxicons-regular';
66
import { theme, font } from '../utils/theme';
77
import rem from '../utils/rem';
88
import { formatDate } from '../utils/formatDate';
99
import { getAuthor } from '../utils/authors';
10-
import Link from './Link';
1110

12-
export function BlogByline({ author, date }: { author: string; date: string }) {
13-
return (
14-
<Byline>
15-
<AuthorName>{author}</AuthorName>
16-
<BlogDivider />
17-
<time dateTime={date}>{formatDate(date)}</time>
18-
</Byline>
19-
);
20-
}
21-
22-
export function BlogColophon({ author, originalUrl }: { author: string; originalUrl: string }) {
11+
export function BlogByline({
12+
author,
13+
date,
14+
originalUrl,
15+
showJumpToComments = false,
16+
}: {
17+
author: string;
18+
date: string;
19+
originalUrl?: string;
20+
showJumpToComments?: boolean;
21+
}) {
2322
const authorData = getAuthor(author);
2423
const websiteIsGithub = authorData.website?.includes('github.com');
2524
const showGithub = authorData.github && !websiteIsGithub;
26-
27-
const hasLinks = authorData.website || showGithub || authorData.twitter;
25+
const crossPosted = originalUrl && new URL(originalUrl).hostname !== 'styled-components.com';
2826

2927
return (
30-
<Colophon>
31-
<ColophonRule aria-hidden="true" />
32-
<ColophonRow>
33-
<ColophonLabel>About the author</ColophonLabel>
34-
<ColophonAuthor>{author}</ColophonAuthor>
35-
</ColophonRow>
36-
{hasLinks && (
37-
<ColophonLinks>
28+
<Byline>
29+
<AuthorName>{author}</AuthorName>
30+
{(authorData.website || showGithub || authorData.twitter) && (
31+
<SocialLinks>
3832
{authorData.website && (
39-
<IconLink href={authorData.website} variant="inline">
33+
<SocialIcon href={authorData.website} target="_blank" rel="noopener" aria-label={`${author} website`}>
4034
<Globe />
41-
{authorData.websiteHost}
42-
</IconLink>
35+
</SocialIcon>
4336
)}
4437
{showGithub && (
45-
<IconLink
38+
<SocialIcon
4639
href={`https://github.com/${authorData.github}`}
47-
variant="inline"
40+
target="_blank"
41+
rel="noopener"
4842
aria-label={`${author} on GitHub`}
4943
>
5044
<Github />
51-
{authorData.github}
52-
</IconLink>
45+
</SocialIcon>
5346
)}
5447
{authorData.twitter && (
55-
<IconLink href={`https://x.com/${authorData.twitter}`} variant="inline" aria-label={`${author} on X`}>
48+
<SocialIcon
49+
href={`https://x.com/${authorData.twitter}`}
50+
target="_blank"
51+
rel="noopener"
52+
aria-label={`${author} on X`}
53+
>
5654
<Twitter />
57-
{authorData.twitter}
58-
</IconLink>
55+
</SocialIcon>
5956
)}
60-
</ColophonLinks>
57+
</SocialLinks>
58+
)}
59+
<BlogDivider />
60+
<time dateTime={date}>{formatDate(date)}</time>
61+
{crossPosted && (
62+
<>
63+
<BlogDivider />
64+
<CrossPostNote>
65+
Originally on{' '}
66+
<CrossPostLink href={originalUrl} target="_blank" rel="noopener">
67+
{new URL(originalUrl).hostname.replace('www.', '')}
68+
</CrossPostLink>
69+
</CrossPostNote>
70+
</>
6171
)}
62-
{originalUrl && new URL(originalUrl).hostname !== 'styled-components.com' && (
63-
<ColophonNote>
64-
Originally published on{' '}
65-
<Link href={originalUrl} variant="inline">
66-
{new URL(originalUrl).hostname.replace('www.', '')}
67-
</Link>
68-
.
69-
</ColophonNote>
72+
{showJumpToComments && (
73+
<JumpToComments href="#comments">
74+
Jump to comments
75+
<ChevronDown />
76+
</JumpToComments>
7077
)}
71-
</Colophon>
78+
</Byline>
7279
);
7380
}
7481

@@ -91,63 +98,62 @@ const AuthorName = styled.span`
9198
letter-spacing: -0.01em;
9299
`;
93100

94-
export const BlogDivider = styled.span.attrs({ 'aria-hidden': 'true', children: '—' })`
95-
color: ${theme.color.blogAccentMuted};
101+
const SocialLinks = styled.span`
102+
display: inline-flex;
103+
align-items: center;
104+
gap: ${rem(8)};
105+
align-self: center;
96106
`;
97107

98-
const Colophon = styled.footer`
99-
margin-top: ${theme.space[16]};
100-
padding-top: ${theme.space[10]};
101-
font-family: ${font.sans};
102-
`;
108+
const SocialIcon = styled.a`
109+
display: inline-flex;
110+
align-items: center;
111+
color: ${theme.color.textMuted};
112+
transition: color ${theme.duration.fast};
103113
104-
const ColophonRule = styled.div`
105-
width: ${rem(64)};
106-
height: 2px;
107-
background: ${theme.color.blogAccent};
108-
opacity: 0.6;
109-
margin-bottom: ${theme.space[8]};
110-
`;
114+
&:hover,
115+
&:focus-visible {
116+
color: ${theme.color.blogAccent};
117+
}
111118
112-
const ColophonRow = styled.div`
113-
display: flex;
114-
flex-direction: column;
115-
gap: ${rem(4)};
116-
margin-bottom: ${theme.space[4]};
119+
svg {
120+
width: ${rem(18)};
121+
height: ${rem(18)};
122+
}
117123
`;
118124

119-
const ColophonLabel = styled.span`
120-
font-size: ${theme.text.xs};
121-
text-transform: uppercase;
122-
letter-spacing: 0.12em;
123-
color: ${theme.color.blogAccent};
124-
font-weight: ${theme.fontWeight.semibold};
125+
const CrossPostNote = styled.span`
126+
font-size: ${theme.text.sm};
127+
color: ${theme.color.textMuted};
125128
`;
126129

127-
const ColophonAuthor = styled.span`
128-
font-family: ${font.display};
129-
font-size: ${theme.text.xl};
130-
font-weight: ${theme.fontWeight.bold};
131-
color: ${theme.color.text};
132-
letter-spacing: -0.01em;
133-
`;
130+
const CrossPostLink = styled.a`
131+
color: inherit;
132+
text-decoration: none;
133+
border-bottom: 1px dotted ${theme.color.textMuted};
134+
transition:
135+
color ${theme.duration.fast},
136+
border-color ${theme.duration.fast};
134137
135-
const ColophonLinks = styled.div`
136-
display: flex;
137-
flex-wrap: wrap;
138-
gap: ${rem(16)};
139-
margin-bottom: ${theme.space[6]};
138+
&:hover,
139+
&:focus-visible {
140+
color: ${theme.color.blogAccent};
141+
border-bottom-color: ${theme.color.blogAccent};
142+
}
140143
`;
141144

142-
const IconLink = styled(Link)`
145+
const JumpToComments = styled.a`
146+
margin-left: auto;
143147
display: inline-flex;
144148
align-items: center;
145-
gap: ${rem(6)};
149+
gap: ${rem(4)};
146150
font-size: ${theme.text.sm};
147-
color: ${theme.color.textSecondary};
148-
transition: color ${theme.duration.normal};
151+
color: ${theme.color.textMuted};
152+
text-decoration: none;
153+
transition: color ${theme.duration.fast};
149154
150-
&:hover {
155+
&:hover,
156+
&:focus-visible {
151157
color: ${theme.color.blogAccent};
152158
}
153159
@@ -157,8 +163,14 @@ const IconLink = styled(Link)`
157163
}
158164
`;
159165

160-
const ColophonNote = styled.p`
161-
font-size: ${theme.text.sm};
162-
color: ${theme.color.textMuted};
163-
margin: 0;
166+
export const BlogDivider = styled.span.attrs({ 'aria-hidden': 'true', children: '—' })`
167+
color: ${theme.color.blogAccentMuted};
168+
`;
169+
170+
export const ColophonRule = styled.div`
171+
width: ${rem(64)};
172+
height: 2px;
173+
background: ${theme.color.blogAccent};
174+
opacity: 0.6;
175+
margin-bottom: ${theme.space[8]};
164176
`;

components/BlogPostPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import DocsLayout from './DocsLayout';
2-
import { BlogByline, BlogColophon } from './BlogMeta';
2+
import { BlogByline } from './BlogMeta';
3+
import BlogComments from './BlogComments';
4+
import Footer from './Footer';
35
import type { Post } from '../utils/blog';
46

57
export default function BlogPostPage({ post, children }: { post: Post; children: React.ReactNode }) {
68
return (
79
<DocsLayout title={post.title}>
8-
<BlogByline author={post.author} date={post.date} />
10+
<BlogByline
11+
author={post.author}
12+
date={post.date}
13+
originalUrl={post.originalUrl}
14+
showJumpToComments={!!post.blueskyPostUrl}
15+
/>
916
{children}
10-
{post.originalUrl && <BlogColophon author={post.author} originalUrl={post.originalUrl} />}
17+
{post.blueskyPostUrl && <BlogComments blueskyPostUrl={post.blueskyPostUrl} />}
18+
<Footer />
1119
</DocsLayout>
1220
);
1321
}

0 commit comments

Comments
 (0)