- - - -
- -
+
+
+
+ +
+ +
+
+ +
+ +
+ +
); }; +const WORDS_PER_MINUTE = 220; + +const readingMinutes = (content: string) => + Math.max(1, Math.round(content.split(/\s+/).length / WORDS_PER_MINUTE)); + const getStaticProps = async (slug: string) => { const postFilePath = path.join(BLOGS_PATH, `${slug}.mdx`); const source = fs.readFileSync(postFilePath); @@ -119,7 +141,6 @@ const getStaticProps = async (slug: string) => { return { content, - scope: data, frontMatter: { ...data, slug, diff --git a/src/app/blog/page.tsx b/src/app/blog/page.tsx index ded2f7271..d5e8ad19c 100644 --- a/src/app/blog/page.tsx +++ b/src/app/blog/page.tsx @@ -1,3 +1,4 @@ +import SectionLabel from '@/components/SectionLabel'; import SubscribeForm from '@/components/SubscribeForm'; import {newCommonMetaTags} from '@/frontend-utils/meta-tags'; import type {IBlogMetadata} from '@/models/mdxFiles'; @@ -17,15 +18,35 @@ async function BlogPage() { props: {blogs}, } = await getStaticProps(); const {t} = await createTranslation('blog'); + const {t: tNewsletter} = await createTranslation('newsletter'); return ( - <> -
-

{t('myBlogs')}

- -
- - +
+
+

+ {t('myBlogs')} +

+

+ {t('postCount', {count: blogs.length})} +

+
+ + + +
+ + {tNewsletter('form.button')} + +

+ {tNewsletter('subtitle')} +

+ +
+
); } diff --git a/src/app/i18n/locales/en/blog.json b/src/app/i18n/locales/en/blog.json index bd7b107ab..a12d6b9ef 100644 --- a/src/app/i18n/locales/en/blog.json +++ b/src/app/i18n/locales/en/blog.json @@ -1,5 +1,18 @@ { "myBlogs": "My Blogs", "filterByTags": "Filter by tags", - "date": "{{val, datetime}}" + "date": "{{val, datetime}}", + "search": "Search posts", + "moreTags": "+{{count}} more", + "fewerTags": "fewer", + "clearTags": "clear", + "noResults": "No posts match that.", + "reset": "Reset filters", + "minRead": "{{count}} min read", + "onThisPage": "On this page", + "share": "Share", + "copyLink": "Copy link", + "copied": "Copied", + "allPosts": "All posts", + "postCount": "{{count}} posts" } diff --git a/src/app/i18n/locales/sv/blog.json b/src/app/i18n/locales/sv/blog.json index 4d11e59df..0eace5aaa 100644 --- a/src/app/i18n/locales/sv/blog.json +++ b/src/app/i18n/locales/sv/blog.json @@ -1,5 +1,18 @@ { "myBlogs": "Mina bloggar", "filterByTags": "Filtrera efter taggar", - "date": "{{val, datetime}}" + "date": "{{val, datetime}}", + "search": "Sök inlägg", + "moreTags": "+{{count}} till", + "fewerTags": "färre", + "clearTags": "rensa", + "noResults": "Inga inlägg matchar.", + "reset": "Återställ filter", + "minRead": "{{count}} min läsning", + "onThisPage": "På den här sidan", + "share": "Dela", + "copyLink": "Kopiera länk", + "copied": "Kopierad", + "allPosts": "Alla inlägg", + "postCount": "{{count}} inlägg" } diff --git a/src/components/ClipboardCopyButton.tsx b/src/components/ClipboardCopyButton.tsx index 8cc6c8fc2..0f8d869ac 100644 --- a/src/components/ClipboardCopyButton.tsx +++ b/src/components/ClipboardCopyButton.tsx @@ -59,23 +59,18 @@ function ClipboardCopyButton({value}: ClipboardCopyButtonProps) { return ( ); } diff --git a/src/components/Coffee.tsx b/src/components/Coffee.tsx deleted file mode 100644 index 0fa499bbd..000000000 --- a/src/components/Coffee.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {SiBuymeacoffee} from '@react-icons/all-files/si/SiBuymeacoffee'; -import Link from './basic/NextLink'; - -function Coffee() { - return ( -
- - - -

Buy me a coffee

-
- -
- ); -} - -export default Coffee; diff --git a/src/components/home/SectionLabel.tsx b/src/components/SectionLabel.tsx similarity index 100% rename from src/components/home/SectionLabel.tsx rename to src/components/SectionLabel.tsx diff --git a/src/components/SubscribeForm.tsx b/src/components/SubscribeForm.tsx index 1e9383887..9d001f2f3 100644 --- a/src/components/SubscribeForm.tsx +++ b/src/components/SubscribeForm.tsx @@ -1,115 +1,97 @@ 'use client'; -import {BiCool} from '@react-icons/all-files/bi/BiCool'; -import {RiSpamLine} from '@react-icons/all-files/ri/RiSpamLine'; +import clsx from 'clsx'; import {useRouter} from 'next/navigation'; import type {SubmitEvent} from 'react'; -import {useState} from 'react'; -import {Fade} from 'react-awesome-reveal'; +import {useId, useState} from 'react'; import {useTranslation} from '../app/i18n/client'; -import Button from './basic/Button'; -import Input from './basic/Input'; import {addSubscriberAction} from './subscribe.action'; -interface FormElements extends HTMLFormControlsCollection { - firstName: HTMLInputElement; - lastName: HTMLInputElement; - email: HTMLInputElement; -} -interface SubscribeFormElement extends HTMLFormElement { - readonly elements: FormElements; -} +const inputClass = + 'w-full min-w-0 rounded-md border border-line bg-canvas px-3 py-2 text-sm outline-none focus:border-accent'; -const SubscribeForm = () => { +/** Newsletter sign-up fields. `inline` puts them on one row from `sm` up. */ +export default function SubscribeForm({ + layout = 'stacked', + className, +}: { + layout?: 'stacked' | 'inline'; + className?: string; +}) { + const {t} = useTranslation('newsletter'); + const router = useRouter(); + const id = useId(); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); - const router = useRouter(); - - const {t} = useTranslation('newsletter'); - - const handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); + const handleSubmit = async (event: SubmitEvent) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); setIsLoading(true); - const {email, firstName, lastName} = ( - e.currentTarget as SubscribeFormElement - ).elements; + setError(''); - await addSubscriberAction({ - email: email.value, - firstName: firstName.value, - lastName: lastName.value, - }).then(res => { - if (res.success) { - router.push('/signup-success'); - } else { - setError(res?.message as string); - } + const result = await addSubscriberAction({ + email: String(form.get('email')), + firstName: String(form.get('firstName')), + lastName: '', }); - setIsLoading(false); + if (result.success) { + router.push('/signup-success'); + } else { + setError(result.message ?? ''); + setIsLoading(false); + } }; return ( -
- -
-
-

{t('title')}

-

- {t('subtitle')} -

-

- {t('noSpam')} - . {t('unsubscribe')} - -

-
- -
-
-

{t('form.title')}

-
-
- - - - {error && ( -
- {error} -
- )} - -
-
-
-
-
+
+
+ + + + + +
+ {error ? ( +

+ {error} +

+ ) : null} +
); -}; - -export default SubscribeForm; +} diff --git a/src/components/basic/Button.tsx b/src/components/basic/Button.tsx deleted file mode 100644 index 61a9f8f70..000000000 --- a/src/components/basic/Button.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {forwardRef, type JSX} from 'react'; - -const Button = forwardRef( - ({className = '', ...props}, ref) => { - let classNames = - 'py-3 px-2 rounded-xl bg-primary-900 text-neutral-50 hover:bg-primary-800 text-light hover:shadow-xs hover:shadow-dark'; - - if (className) { - classNames += ` ${className}`; - } - - return - +
& {latestBlog?: IBlogMetadata}; diff --git a/src/components/home/SideProjects.tsx b/src/components/home/SideProjects.tsx index e6e745df7..d8bdfc208 100644 --- a/src/components/home/SideProjects.tsx +++ b/src/components/home/SideProjects.tsx @@ -1,5 +1,5 @@ import {createTranslation} from '@/app/i18n/server'; -import SectionLabel from './SectionLabel'; +import SectionLabel from '@/components/SectionLabel'; import {sideProjects} from './side-projects'; export default async function SideProjects() { diff --git a/src/components/mdx.tsx b/src/components/mdx.tsx deleted file mode 100644 index bb74b5b1a..000000000 --- a/src/components/mdx.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import {evaluate, type EvaluateOptions} from '@mdx-js/mdx'; - -import * as runtime from 'react/jsx-runtime'; - -export interface MDXProps extends Omit< - EvaluateOptions, - 'Fragment' | 'jsx' | 'jsxDEV' | 'jsxs' | 'development' -> { - source: Parameters[0]; -} -export async function MDX(props: MDXProps) { - const {source, ...rest} = props; - const {default: MDXContent} = await evaluate(source, { - ...rest, - ...(runtime as Pick), - }); - - return ; -} diff --git a/src/modules/blog/BlogContent.tsx b/src/modules/blog/BlogContent.tsx index b0dbc4c0a..05367d6b8 100644 --- a/src/modules/blog/BlogContent.tsx +++ b/src/modules/blog/BlogContent.tsx @@ -1,43 +1,36 @@ -'use client'; -import type {FunctionComponent, ComponentProps, JSX} from 'react'; +import {evaluate, type EvaluateOptions} from '@mdx-js/mdx'; +import type {ComponentProps, FunctionComponent, JSX} from 'react'; +import * as runtime from 'react/jsx-runtime'; +import rehypeMdxCodeProps from 'rehype-mdx-code-props'; import ContentAnchor from '../common/AnchorTag'; import ContentBlockQuote from '../common/BlockquoteTag'; import ContentBookMark from '../common/BookmarkElement'; import ContentCodeBlock from '../common/CodeBlockElement'; import ContentImage from '../common/ImageTag'; -import ContentListElement from '../common/ListElementTag'; -import ContentParagraph from '../common/ParagraphTag'; -import TableOfContents from './TableOfContents'; -import {MDX} from '@/components/mdx'; -import rehypeMdxCodeProps from 'rehype-mdx-code-props'; +import rehypeHeadingIds, {type TocItem} from './rehype-heading-ids'; const components: ComponentProps = { inlineCode: ({children}: {children: JSX.Element}) => {children}, blockquote: ContentBlockQuote, Anchor: ContentAnchor, a: ContentAnchor, - p: ContentParagraph, - li: ContentListElement, Img: ContentImage, Bookmark: ContentBookMark, - TableOfContents: TableOfContents, + // Older posts carry a hand-written TOC; the page now builds one for every + // post from its headings, so these render nothing. + TableOfContents: () => null, pre: ContentCodeBlock, }; -type Props = { - content: string; - scope: Record | undefined; -}; +/** Compiles a post and returns its content plus the h2/h3 outline. */ +export async function renderBlog(source: string) { + const toc: TocItem[] = []; + const {default: MDXContent} = await evaluate(source, { + ...(runtime as Pick), + rehypePlugins: [rehypeMdxCodeProps, [rehypeHeadingIds, {toc}]], + }); -function BlogContent({content, scope: _scope}: Props) { - return ( - components} - source={content} - rehypePlugins={[rehypeMdxCodeProps]} - remarkPlugins={[]} - /> - ); -} + const Content = () => ; -export default BlogContent; + return {Content, toc}; +} diff --git a/src/modules/blog/BlogFooter.tsx b/src/modules/blog/BlogFooter.tsx index 16a937573..389fbda9a 100644 --- a/src/modules/blog/BlogFooter.tsx +++ b/src/modules/blog/BlogFooter.tsx @@ -1,46 +1,95 @@ 'use client'; -import {FaEnvelopeOpenText} from '@react-icons/all-files/fa/FaEnvelopeOpenText'; -import {FaTwitter} from '@react-icons/all-files/fa/FaTwitter'; -import Coffee from '../../components/Coffee'; -import SubscribeButton from '../../components/basic/Button'; +import {useState} from 'react'; +import {useTranslation} from '../../app/i18n/client'; +import SectionLabel from '../../components/SectionLabel'; +import SubscribeForm from '../../components/SubscribeForm'; import NextLink from '../../components/basic/NextLink'; -import {IBlogMetadata} from '../../models/mdxFiles'; -import ShareToSocialMedia from './ShareToSocialMedia'; +import type {IBlogMetadata} from '../../models/mdxFiles'; + +const linkClass = 'text-xs lowercase transition-colors hover:text-accent'; + +function CopyLinkButton({url}: {url: string}) { + const {t} = useTranslation('blog'); + const [isCopied, setIsCopied] = useState(false); + + return ( + + ); +} const BlogFooter = ({blog}: {blog: IBlogMetadata}) => { + const {t} = useTranslation('blog'); + const {t: tNewsletter} = useTranslation('newsletter'); + const url = `https://carlogino.com/blog/${blog.slug}`; + const encodedUrl = encodeURIComponent(url); + return ( -
- - -

- If you find this useful and you want to support me -

- - -

- Get latest updates directly into your mailbox. -

-
- - - Subscribe to my Newsletter - -
- -

- Connect with me -

+ ); diff --git a/src/modules/blog/BlogHeader.tsx b/src/modules/blog/BlogHeader.tsx index 7a443b0d3..f1734018c 100644 --- a/src/modules/blog/BlogHeader.tsx +++ b/src/modules/blog/BlogHeader.tsx @@ -1,59 +1,92 @@ -'use client'; -import type {FunctionComponent} from 'react'; -import {useTranslation} from '../../app/i18n/client'; +import {createTranslation} from '../../app/i18n/server'; import {BlurringImage} from '../../components/BlurringImage'; +import NextLink from '../../components/basic/NextLink'; import type {IBlogMetadata} from '../../models/mdxFiles'; import type {BlurImageType} from '../../utils/image-blur.utils'; type Props = { blog: IBlogMetadata; + readingMinutes: number; // Optional: drafts have no banner asset mapped yet. } & Partial>; -const BlogHeader: FunctionComponent = ({blog, img, svg}) => { - const {t} = useTranslation('common'); +const formatDate = (date: string) => + new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date(date)); + +export default async function BlogHeader({ + blog, + readingMinutes, + img, + svg, +}: Props) { + const {t} = await createTranslation('blog'); return ( -
-

{blog.title}

- {blog.description && ( -

{blog.description}

- )} -

- {t('date', { - val: new Date(blog.date), - formatParams: { - val: { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }, - }, - })} +

+ + ← {t('allPosts').toLowerCase()} + +

+ {blog.title} +

+ {blog.description ? ( +

+ {blog.description} +

+ ) : null} +

+ + + · + + {t('minRead', {count: readingMinutes})} + {blog.tags?.length ? ( + <> + + · + + {blog.tags.map(tag => ( + + #{tag} + + ))} + + ) : null}

{img && svg && !blog.hideBanner ? ( -
- -
+
+
+ +
+ {blog.bannerDescription ? ( +
+ {blog.bannerDescription} +
+ ) : null} +
) : null} - {blog.bannerDescription ? ( - - ) : null} -
-
+