Skip to content

Commit 27e8728

Browse files
committed
Update blog page layout
1 parent 0273103 commit 27e8728

11 files changed

+910
-1071
lines changed

.biomelintrc-auto-import.json

+2
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
"Link",
1414
"NavHeader",
1515
"NavLink",
16+
"OutputMetadata",
1617
"ProjectCard",
1718
"ProjectsPage",
19+
"ShareButtons",
1820
"Show",
1921
"Suspense",
2022
"createMeta",
72.4 KB
Loading

app/.server/notion.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const POSTS_DATABASE_ID = 'c733986f-2b63-4490-9f8d-81c12332892c';
2121
const PROJECTS_DATABASE_ID = '2fcde118-7914-4b4c-b753-62e72893e6d8';
2222

2323
function getNotion(context: AppLoadContext): INotion.Client {
24-
console.log('context', context);
2524
return new Notion.Client({
2625
auth: context.cloudflare.env.NOTION_TOKEN,
2726
});
@@ -47,7 +46,7 @@ function getCachifiedDefaults(context: AppLoadContext) {
4746
// if cached longer than 1 day the images will get out of date
4847
staleWhileRevalidate: 1000 * 60 * 60, // 1 hour
4948
// forceFresh: import.meta.env.DEV,
50-
forceFresh: true,
49+
// forceFresh: false,
5150
};
5251
}
5352

app/components/ShareButtons.tsx

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
interface ShareButtonsProps {
4+
url: string;
5+
title: string;
6+
}
7+
8+
export function ShareButtons({ url, title }: ShareButtonsProps) {
9+
const twitterRef = useRef<HTMLAnchorElement>(null);
10+
const linkedinRef = useRef<HTMLAnchorElement>(null);
11+
const blueskyRef = useRef<HTMLAnchorElement>(null);
12+
13+
useEffect(() => {
14+
const fullUrl = `${window.location.origin}${url}`;
15+
if (twitterRef.current) {
16+
twitterRef.current.href = `https://twitter.com/intent/tweet?text=${
17+
encodeURIComponent(title)
18+
}&url=${encodeURIComponent(fullUrl)}`;
19+
}
20+
if (linkedinRef.current) {
21+
linkedinRef.current.href =
22+
`https://www.linkedin.com/sharing/share-offsite/?url=${
23+
encodeURIComponent(fullUrl)
24+
}`;
25+
}
26+
if (blueskyRef.current) {
27+
blueskyRef.current.href = `https://bsky.app/intent/compose?text=${
28+
encodeURIComponent(`${title}\n\n${fullUrl}`)
29+
}`;
30+
}
31+
}, [url, title]);
32+
33+
return (
34+
<div className='flex gap-4' role='group' aria-label='Share article'>
35+
<a
36+
ref={twitterRef}
37+
href={`https://twitter.com/intent/tweet?text=${
38+
encodeURIComponent(title)
39+
}&url=${encodeURIComponent(url)}`}
40+
target='_blank'
41+
rel='noopener noreferrer'
42+
className='text-blue-400 hover:text-blue-600'
43+
>
44+
Share on Twitter
45+
</a>
46+
<a
47+
ref={linkedinRef}
48+
href={`https://www.linkedin.com/sharing/share-offsite/?url=${
49+
encodeURIComponent(url)
50+
}`}
51+
target='_blank'
52+
rel='noopener noreferrer'
53+
className='text-blue-700 hover:text-blue-900'
54+
>
55+
Share on LinkedIn
56+
</a>
57+
<a
58+
ref={blueskyRef}
59+
href={`https://bsky.app/intent/compose?text=${
60+
encodeURIComponent(`${title}\n\n${url}`)
61+
}`}
62+
target='_blank'
63+
rel='noopener noreferrer'
64+
className='text-sky-500 hover:text-sky-700'
65+
>
66+
Share on Bluesky
67+
</a>
68+
</div>
69+
);
70+
}

app/routes/_main.posts.$slug.tsx

+45-45
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,72 @@
1-
import { type LoaderFunctionArgs } from 'react-router';
1+
import { type LoaderFunctionArgs, useRouteError } from 'react-router';
22
import { type MetaFunction, useLoaderData } from 'react-router';
33
import { z } from 'zod';
44
import { getPost } from '~/.server/notion';
5+
import { formatDate } from '~/utils/dates';
56

67
export async function loader({ params, context }: LoaderFunctionArgs) {
78
const { slug } = z.object({ slug: z.string() }).parse(params);
89

9-
// if (z.string().uuid(slug).safeParse(slug).success === false) {
10-
// // invalid uuid should be not found
11-
// // if passed on to the Notion API it would trigger an error
12-
// throw new Response('Not found', { status: 404 });
13-
// }
14-
1510
const post = await getPost(context, slug);
1611

17-
// const db = createDrizzle(context.cloudflare.env.DB);
18-
19-
// const post = await db.query.post.findFirst({
20-
// where: eq(schema.post.slug, slug),
21-
// columns: {
22-
// slug: true,
23-
// title: true,
24-
// date: true,
25-
// text: true,
26-
// },
27-
// });
28-
2912
if (!post) throw new Response('Not found', { status: 404 });
3013

31-
const html = '';
32-
// const html = await markdownToHtml(post.text);
33-
34-
return ({ post: post, html });
14+
return ({ post: post });
3515
}
3616

3717
export const meta: MetaFunction<typeof loader> = ({ data }) => {
18+
if (!data?.post) return [{ title: 'Post Not Found' }];
19+
3820
return createMeta({
39-
title: data?.post.title,
40-
url: `/posts/${data?.post.slug}`,
21+
title: data.post.title,
22+
url: `/posts/${data.post.slug}`,
23+
description: data.post.excerpt || data.post.title,
24+
type: 'article',
25+
article: {
26+
publishedTime: data.post.date ?? undefined,
27+
authors: ['Patrick Miller'],
28+
},
4129
});
4230
};
4331

32+
export function ErrorBoundary() {
33+
const error = useRouteError();
34+
35+
console.error(error);
36+
37+
return (
38+
<Container>
39+
<h1 className='text-2xl font-bold'>Error</h1>
40+
<p>Sorry, this post could not be found.</p>
41+
</Container>
42+
);
43+
}
44+
4445
export default function PostPage() {
45-
const { post, html } = useLoaderData<typeof loader>();
46+
const { post } = useLoaderData<typeof loader>();
4647
const { slug, title, date } = post;
47-
48-
const url = `/posts/${slug}`;
48+
const formattedDate = date ? formatDate(date) : null;
4949

5050
return (
51-
<Container className='grid gap-4 py-4 overflow-x-hidden w-screen'>
52-
{/* <!-- <article className="mb-32"> --> */}
53-
{
54-
/* <NextSeo
55-
title={`${post.title} | Patrick Miller`}
56-
openGraph={{
57-
images: post.ogImage ? [{ url: post.ogImage.url }] : [],
58-
}}
59-
/> */
60-
}
61-
<h1 className='mb-4 text-center text-6xl font-bold leading-tight tracking-tighter md:text-left md:text-7xl md:leading-none lg:text-8xl'>
62-
<Link to={url}>{title}</Link>
63-
</h1>
51+
<article className='max-w-2xl mx-auto px-4'>
52+
<header className='mb-12'>
53+
<h1 className='mb-6 text-4xl md:text-5xl font-bold leading-snug tracking-tight md:leading-tight'>
54+
{title}
55+
</h1>
56+
{date && (
57+
<time dateTime={date} className='text-gray-600 text-lg'>
58+
{formattedDate}
59+
</time>
60+
)}
61+
</header>
6462

65-
<div className='mb-8 font-bold'>{date}</div>
66-
{/* <!-- w-[calc(100vw-2.5rem)] --> */}
67-
<div className='prose max-w-full overflow-x-hidden'>
63+
<div className='prose overflow-x-hidden'>
6864
<Blocks blocks={post.content} />
6965
</div>
70-
</Container>
66+
67+
<footer className='mt-8 border-t pt-8'>
68+
<ShareButtons url={`/posts/${slug}`} title={title} />
69+
</footer>
70+
</article>
7171
);
7272
}

app/shared/create-meta.tsx

+32-9
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,42 @@
11
import type { MetaDescriptor } from 'react-router';
22
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '~/config';
33

4+
interface CreateMetaOptions {
5+
title?: string | null;
6+
description?: string | null;
7+
image?: string | null;
8+
url?: string | null;
9+
type?: 'website' | 'article';
10+
article?: {
11+
publishedTime?: string;
12+
authors?: string[];
13+
};
14+
}
15+
416
export const createMeta = ({
517
title,
618
description,
719
image,
820
url,
9-
}: {
10-
title?: string | null;
11-
description?: string | null;
12-
image?: string | null;
13-
url?: string | null;
14-
} = {}): MetaDescriptor[] => {
21+
type = 'website',
22+
article,
23+
}: CreateMetaOptions = {}): MetaDescriptor[] => {
1524
title = title || SITE_TITLE;
1625
description = description || SITE_DESCRIPTION;
1726

1827
const canonicalUrl = url ? new URL(url, BASE_URL).toString() : undefined;
1928

2029
// I can't get Vercel OG or similar to run in Cloudflare Pages so hosting it
2130
// seperately at pmil-me-og.vercel.app
22-
image =
23-
image ||
31+
image = image ||
2432
`https://pmil-me-og.vercel.app/api/og?title=${encodeURIComponent(title)}`;
2533

2634
const items: MetaDescriptor[] = [
2735
{ title: title },
2836
{ name: 'description', content: description },
2937
{ name: 'title', content: title },
3038
{ tagName: 'link', rel: 'canonical', href: canonicalUrl },
31-
{ property: 'og:type', content: 'website' },
39+
{ property: 'og:type', content: type },
3240
{ property: 'og:url', content: canonicalUrl },
3341
{ property: 'og:title', content: title },
3442
{ property: 'og:description', content: description },
@@ -46,6 +54,21 @@ export const createMeta = ({
4654
},
4755
];
4856

57+
// Add article-specific meta tags
58+
if (type === 'article' && article) {
59+
if (article.publishedTime) {
60+
items.push({
61+
property: 'article:published_time',
62+
content: article.publishedTime,
63+
});
64+
}
65+
if (article.authors?.length) {
66+
article.authors.forEach((author) => {
67+
items.push({ property: 'article:author', content: author });
68+
});
69+
}
70+
}
71+
4972
return items.filter(
5073
(item) => item && Object.values(item).every((value) => value != null),
5174
);

app/utils/dates.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function formatDate(date: string) {
2+
return new Intl.DateTimeFormat('en-US', {
3+
year: 'numeric',
4+
month: 'long',
5+
day: 'numeric',
6+
}).format(new Date(date));
7+
}

auto-imports.d.ts

+7
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ declare global {
2121
const PostPreview: typeof import('./app/components/PostPreview')['default']
2222
const ProjectCard: typeof import('./app/components/project-card')['ProjectCard']
2323
const ProjectsPage: typeof import('./app/components/project-card')['ProjectsPage']
24+
const ShareButtons: typeof import('./app/components/ShareButtons')['ShareButtons']
2425
const Show: typeof import('./app/components/Show')['default']
2526
const Suspense: typeof import('react')['Suspense']
2627
const createMeta: typeof import('./app/shared/create-meta')['createMeta']
@@ -48,3 +49,9 @@ declare global {
4849
const useSyncExternalStore: typeof import('react')['useSyncExternalStore']
4950
const useTransition: typeof import('react')['useTransition']
5051
}
52+
// for type re-export
53+
declare global {
54+
// @ts-ignore
55+
export type { OutputMetadata } from './app/shared/utils'
56+
import('./app/shared/utils')
57+
}

package.json

+13-13
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"dependencies": {
2323
"@notionhq/client": "2.2.15",
2424
"@radix-ui/react-icons": "1.3.2",
25-
"@radix-ui/react-slot": "1.1.0",
25+
"@radix-ui/react-slot": "1.1.1",
2626
"@react-router/cloudflare": "^7.0.0",
2727
"@react-router/node": "7.0.2",
2828
"@resvg/resvg-wasm": "2.6.2",
@@ -31,7 +31,7 @@
3131
"clsx": "2.1.1",
3232
"drizzle-orm": "0.36.4",
3333
"humanize-url": "3.0.0",
34-
"isbot": "5.1.17",
34+
"isbot": "5.1.18",
3535
"lodash-es": "4.17.21",
3636
"lucide-react": "0.468.0",
3737
"notion-client": "7.1.5",
@@ -40,7 +40,7 @@
4040
"react": "19.0.0",
4141
"react-dom": "19.0.0",
4242
"react-loops": "1.3.0",
43-
"react-notion-x": "6.16.0",
43+
"react-notion-x": "7.2.5",
4444
"react-router": "^7.0.0",
4545
"rehype-sanitize": "6.0.0",
4646
"rehype-stringify": "10.0.1",
@@ -51,19 +51,19 @@
5151
"sharp": "0.33.5",
5252
"solid-js": "1.9.3",
5353
"tailwind-merge": "2.5.5",
54-
"tailwindcss": "3.4.16",
54+
"tailwindcss": "3.4.17",
5555
"tailwindcss-animate": "1.0.7",
5656
"ulidx": "2.4.1",
5757
"unified": "11.0.5",
5858
"yoga-wasm-web": "0.3.3",
59-
"zod": "3.23.8"
59+
"zod": "3.24.1"
6060
},
6161
"devDependencies": {
6262
"@biomejs/biome": "1.9.4",
63-
"@cloudflare/workers-types": "4.20241216.0",
63+
"@cloudflare/workers-types": "4.20241218.0",
6464
"@epic-web/cachified": "5.2.0",
6565
"@hiogawa/vite-node-miniflare": "0.1.1",
66-
"@iconify-json/ion": "1.2.1",
66+
"@iconify-json/ion": "1.2.2",
6767
"@libsql/client": "0.14.0",
6868
"@mdx-js/rollup": "3.1.0",
6969
"@react-router/dev": "^7.0.0",
@@ -75,15 +75,15 @@
7575
"@rollup/plugin-node-resolve": "16.0.0",
7676
"@rollup/plugin-replace": "6.0.2",
7777
"@rollup/plugin-typescript": "12.1.2",
78-
"@shikijs/rehype": "1.24.1",
78+
"@shikijs/rehype": "1.24.2",
7979
"@svgr/core": "8.1.0",
8080
"@svgr/plugin-jsx": "8.1.0",
8181
"@tailwindcss/aspect-ratio": "0.4.2",
8282
"@tailwindcss/typography": "0.5.15",
8383
"@types/lodash-es": "4.17.12",
8484
"@types/path-browserify": "1.0.3",
85-
"@types/react": "19.0.0",
86-
"@types/react-dom": "19.0.0",
85+
"@types/react": "19.0.2",
86+
"@types/react-dom": "19.0.2",
8787
"autoprefixer": "10.4.20",
8888
"cachified-adapter-cloudflare-kv": "2.3.0",
8989
"drizzle-kit": "0.28.1",
@@ -95,9 +95,9 @@
9595
"tailwindcss": "3.4.15",
9696
"typescript": "5.7.2",
9797
"unenv": "1.10.0",
98-
"unplugin-auto-import": "0.18.6",
99-
"unplugin-icons": "0.21.0",
100-
"vite": "^5.4.11",
98+
"unplugin-auto-import": "0.19.0",
99+
"unplugin-icons": "0.22.0",
100+
"vite": "^6.0.3",
101101
"vite-imagetools": "7.0.5",
102102
"vite-node": "2.1.8",
103103
"vite-plugin-cjs-interop": "2.1.6",

0 commit comments

Comments
 (0)