From 5b7ccf0b671e2999b62befc729a3e517a0433728 Mon Sep 17 00:00:00 2001 From: Bertrand Yuan Date: Mon, 15 Dec 2025 23:48:10 +0800 Subject: initial commit -- the front-end prototype The initial code is base on Anirudh's work. More to see at: https://github.com/techwithanirudh/shadcn-blog Therefore, the code in this commit is under MIT license. --- src/app/(home)/(mdx)/about/page.mdx | 24 ++++ src/app/(home)/_components/call-to-action.tsx | 23 ++++ src/app/(home)/_components/hero.tsx | 98 ++++++++++++++ src/app/(home)/_components/posts.tsx | 40 ++++++ src/app/(home)/actions.ts | 78 +++++++++++ src/app/(home)/layout.tsx | 31 +++++ src/app/(home)/page.tsx | 29 +++++ src/app/(home)/posts/[slug]/page.client.tsx | 57 ++++++++ src/app/(home)/posts/[slug]/page.tsx | 145 +++++++++++++++++++++ src/app/(home)/posts/page.tsx | 133 +++++++++++++++++++ src/app/(home)/tags/[...slug]/page.tsx | 180 ++++++++++++++++++++++++++ src/app/(home)/tags/page.tsx | 57 ++++++++ 12 files changed, 895 insertions(+) create mode 100644 src/app/(home)/(mdx)/about/page.mdx create mode 100644 src/app/(home)/_components/call-to-action.tsx create mode 100644 src/app/(home)/_components/hero.tsx create mode 100644 src/app/(home)/_components/posts.tsx create mode 100644 src/app/(home)/actions.ts create mode 100644 src/app/(home)/layout.tsx create mode 100644 src/app/(home)/page.tsx create mode 100644 src/app/(home)/posts/[slug]/page.client.tsx create mode 100644 src/app/(home)/posts/[slug]/page.tsx create mode 100644 src/app/(home)/posts/page.tsx create mode 100644 src/app/(home)/tags/[...slug]/page.tsx create mode 100644 src/app/(home)/tags/page.tsx (limited to 'src/app/(home)') diff --git a/src/app/(home)/(mdx)/about/page.mdx b/src/app/(home)/(mdx)/about/page.mdx new file mode 100644 index 0000000..675f3a8 --- /dev/null +++ b/src/app/(home)/(mdx)/about/page.mdx @@ -0,0 +1,24 @@ +import MdxLayout from '@/components/mdx-layout'; + + +Hey, I'm a **Senior Software Engineer** at Company. I focus on building fast, accessible, and visually polished front-end experiences using tools like **Next.js**, **TypeScript**, and whatever cool thing just dropped on GitHub. + +This portfolio is built with **Next.js** and powered by [Fumadocs](https://fumadocs.vercel.app/), which makes writing pages in Markdown feel like second nature. It lets me focus on content without wrestling with layout. + +### Journey + +I didn't grow up writing code. In fact, I didn't even know what a `
` was until much later. My curiosity started with tweaking themes on forums, editing little bits of CSS without knowing what CSS even meant. + +Eventually, that curiosity turned into late nights spent debugging JavaScript errors and falling in love with how code could bring ideas to life. + +I started building side projects, contributing to open source, and slowly leveling up. My first "real" job involved wrangling legacy jQuery code. It wasn't glamorous, but it taught me the fundamentals — and just how far modern frameworks have come. + +Since then, I've worked across startups and mid-sized teams, shipped production code to thousands of users, and mentored new developers along the way. + +### Socials + +- Twitter [@yourname](https://twitter.com/yourname) +- GitHub [@yourname](https://github.com/yourname) +- Instagram [@yourname](https://instagram.com/yourname) +- Email your@name.com + \ No newline at end of file diff --git a/src/app/(home)/_components/call-to-action.tsx b/src/app/(home)/_components/call-to-action.tsx new file mode 100644 index 0000000..b75298e --- /dev/null +++ b/src/app/(home)/_components/call-to-action.tsx @@ -0,0 +1,23 @@ +import { NewsletterForm } from '@/components/newsletter-form'; +import { Section } from '@/components/section'; +import type React from 'react'; + +export function CTA(): React.ReactElement { + return ( +
+
+

+ Subscribe to the Newsletter +

+

+ Get the latest articles and updates delivered straight to your inbox. + No spam, unsubscribe anytime. +

+
+ +
+ +
+
+ ); +} diff --git a/src/app/(home)/_components/hero.tsx b/src/app/(home)/_components/hero.tsx new file mode 100644 index 0000000..8ac251b --- /dev/null +++ b/src/app/(home)/_components/hero.tsx @@ -0,0 +1,98 @@ +import { baseOptions, linkItems } from '@/app/layout.config'; +import { Icons } from '@/components/icons/icons'; +import { Section } from '@/components/section'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { getLinks } from 'fumadocs-ui/layouts/shared'; +import * as motion from 'motion/react-client'; +import Image from 'next/image'; +import Link from 'next/link'; +import Balancer from 'react-wrap-balancer'; +import heroImage from '../../../../public/images/gradient-noise-purple-azure-light.png'; + +const Hero = () => { + const links = getLinks(linkItems, baseOptions.githubUrl); + const navItems = links.filter((item) => + ['nav', 'all'].includes(item.on ?? 'all'), + ); + + return ( +
+ + Hero Background + +
+ + + Full-Stack Developer & Tech Writer + +
+

+ I'm John Doe , a Full-Stack Developer. +

+

+ + I write about web development, software engineering, and the latest + technologies. I also create fun projects and tutorials to help you + learn and grow as a developer. + +

+ +
+ + Browse Posts + + + +
+ {navItems + .filter((item) => item.type === 'icon') + .map((item, i) => ( + + {item.icon} + {item.text} + + ))} +
+
+
+ ); +}; + +export default Hero; diff --git a/src/app/(home)/_components/posts.tsx b/src/app/(home)/_components/posts.tsx new file mode 100644 index 0000000..8c8dc33 --- /dev/null +++ b/src/app/(home)/_components/posts.tsx @@ -0,0 +1,40 @@ +import { Icons } from '@/components/icons/icons'; +import { PostCard } from '@/components/posts/post-card'; +import { Section } from '@/components/section'; +import { buttonVariants } from '@/components/ui/button'; +import type { Page } from '@/lib/source'; +import Link from 'next/link'; + +export default function Posts({ posts }: { posts: Page[] }) { + return ( +
+
+ {posts.map((post) => { + const date = new Date(post.data.date).toDateString(); + return ( + + ); + })} + + View More + + +
+
+ ); +} diff --git a/src/app/(home)/actions.ts b/src/app/(home)/actions.ts new file mode 100644 index 0000000..fdb16ca --- /dev/null +++ b/src/app/(home)/actions.ts @@ -0,0 +1,78 @@ +'use server'; + +import { getContact, sendWelcomeEmail, updateContact } from '@/lib/resend'; +import { ActionError, actionClient } from '@/lib/safe-action'; +import { getSortedByDatePosts } from '@/lib/source'; +import { NewsletterSchema } from '@/lib/validators'; +import { getSession } from '@/server/auth'; +import { Resend } from 'resend'; + +const resend = new Resend(process.env.RESEND_API_KEY as string); +const audienceId = process.env.RESEND_AUDIENCE_ID as string; + +const splitName = (name = '') => { + const [firstName, ...lastName] = name.split(' ').filter(Boolean); + return { + firstName: firstName, + lastName: lastName.join(' '), + }; +}; + +export const subscribeUser = actionClient + .schema(NewsletterSchema) + .action(async ({ parsedInput: { email } }) => { + const session = await getSession(); + const fullName = session?.user.name || ''; + const { firstName, lastName } = fullName + ? splitName(fullName) + : { firstName: '', lastName: '' }; + + try { + const contact = await getContact({ email, audienceId }); + + if (contact) { + await updateContact({ + email, + firstName, + lastName, + audienceId, + unsubscribed: false, + }); + + return { + success: true, + message: 'You are already subscribed to our newsletter!', + }; + } + + const { data, error } = await resend.contacts.create({ + email, + audienceId, + firstName, + lastName, + unsubscribed: false, + }); + + if (!data || error) { + throw new Error( + `Failed to create contact: ${error?.message || 'Unknown error'}`, + ); + } + + const posts = getSortedByDatePosts(); + await sendWelcomeEmail({ + posts, + to: email, + firstName: firstName || 'there', + }); + + return { + success: true, + message: 'You are now subscribed to our newsletter!', + }; + } catch (error) { + console.error('Failed to subscribe:', error); + if (error instanceof ActionError) throw error; + throw new ActionError('Oops, something went wrong while subscribing.'); + } + }); diff --git a/src/app/(home)/layout.tsx b/src/app/(home)/layout.tsx new file mode 100644 index 0000000..bd641df --- /dev/null +++ b/src/app/(home)/layout.tsx @@ -0,0 +1,31 @@ +import { Footer } from '@/components/sections/footer'; +import { Header } from '@/components/sections/header'; +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { getLinks } from 'fumadocs-ui/layouts/shared'; +import type { ReactNode } from 'react'; +import { baseOptions, linkItems } from '../layout.config'; + +const Layout = ({ children }: { children: ReactNode }) => { + return ( + + ), + }} + className='pt-0' + > +
+ {children} +
+
+
+ ); +}; + +export default Layout; diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx new file mode 100644 index 0000000..da7da0f --- /dev/null +++ b/src/app/(home)/page.tsx @@ -0,0 +1,29 @@ +import Hero from '@/app/(home)/_components/hero'; +import Posts from '@/app/(home)/_components/posts'; +import { Icons } from '@/components/icons/icons'; +import { Section } from '@/components/section'; +import Separator from '@/components/separator'; +import { getSortedByDatePosts } from '@/lib/source'; +import { CTA } from './_components/call-to-action'; + +export default function Home() { + const posts = getSortedByDatePosts().slice(0, 3); + + return ( + <> + +
+

+ + Posts{' '} + + +

+
+ + + + + + ); +} diff --git a/src/app/(home)/posts/[slug]/page.client.tsx b/src/app/(home)/posts/[slug]/page.client.tsx new file mode 100644 index 0000000..7a97f56 --- /dev/null +++ b/src/app/(home)/posts/[slug]/page.client.tsx @@ -0,0 +1,57 @@ +'use client'; +import { + UploadIcon as ShareIcon, + type UploadIconHandle as ShareIconHandle, +} from '@/components/icons/animated/upload'; +import { Icons } from '@/components/icons/icons'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { Comments } from '@fuma-comment/react'; +import { redirect } from 'next/navigation'; +import { useRef } from 'react'; +import { toast } from 'sonner'; +import { useCopyToClipboard } from 'usehooks-ts'; + +export function Share({ url }: { url: string }): React.ReactElement { + const iconRef = useRef(null); + const [_, copyToClipboard] = useCopyToClipboard(); + + const onClick = async (): Promise => { + await copyToClipboard(`${window.location.origin}${url}`); + toast.success('Copied to clipboard!', { + icon: , + description: 'The post link has been copied to your clipboard.', + }); + }; + + return ( + + ); +} + +export function PostComments({ + slug, + className, +}: { slug: string; className?: string }) { + return ( + { + redirect('/login'); + }, + }} + /> + ); +} diff --git a/src/app/(home)/posts/[slug]/page.tsx b/src/app/(home)/posts/[slug]/page.tsx new file mode 100644 index 0000000..15a6bfd --- /dev/null +++ b/src/app/(home)/posts/[slug]/page.tsx @@ -0,0 +1,145 @@ +import { PostComments, Share } from '@/app/(home)/posts/[slug]/page.client'; +import { PostJsonLd } from '@/components/json-ld'; +import { Section } from '@/components/section'; +import { TagCard } from '@/components/tags/tag-card'; +import { createMetadata } from '@/lib/metadata'; +import { metadataImage } from '@/lib/metadata-image'; +import { type Page as MDXPage, getPost, getPosts } from '@/lib/source'; +import { cn } from '@/lib/utils'; +import { File, Files, Folder } from 'fumadocs-ui/components/files'; +import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; +import { Tab, Tabs } from 'fumadocs-ui/components/tabs'; +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Balancer from 'react-wrap-balancer'; +import { description as homeDescription } from 'src/app/layout.config'; + +function Header(props: { page: MDXPage; tags?: string[] }) { + const { page, tags } = props; + + return ( +
+
+
+

+ {page.data.title} +

+

+ {page.data.description} +

+
+
+ {tags?.map((tag) => ( + + ))} +
+
+
+ ); +} + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + const page = getPost([params.slug]); + + if (!page) notFound(); + const { body: Mdx, toc, tags, lastModified } = page.data; + + const lastUpdate = lastModified ? new Date(lastModified) : undefined; + + return ( + <> +
+ +
+
+
+ +
+ +
+ +
+
+
+

Written by

+

{page.data.author}

+
+
+

+ Created At +

+

+ {new Date(page.data.date ?? page.file.name).toDateString()} +

+
+ {lastUpdate && ( +
+

+ Updated At +

+

{lastUpdate.toDateString()}

+
+ )} + +
+
+
+ + + ); +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise { + const params = await props.params; + const page = getPost([params.slug]); + + if (!page) notFound(); + + const title = page.data.title; + const description = page.data.description ?? homeDescription; + + return createMetadata( + metadataImage.withImage(page.slugs, { + title, + description, + openGraph: { + url: `/posts/${page.slugs.join('/')}`, + }, + alternates: { + canonical: page.url, + }, + }), + ); +} + +export function generateStaticParams(): { slug: string | undefined }[] { + return getPosts().map((page) => ({ + slug: page.slugs[0], + })); +} diff --git a/src/app/(home)/posts/page.tsx b/src/app/(home)/posts/page.tsx new file mode 100644 index 0000000..fd0f912 --- /dev/null +++ b/src/app/(home)/posts/page.tsx @@ -0,0 +1,133 @@ +import { postsPerPage } from '@/app/layout.config'; +import { NumberedPagination } from '@/components/numbered-pagination'; +import { PostCard } from '@/components/posts/post-card'; +import { Section } from '@/components/section'; +import { createMetadata } from '@/lib/metadata'; +import { getSortedByDatePosts } from '@/lib/source'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { notFound, redirect } from 'next/navigation'; + +export const dynamicParams = false; + +const totalPosts = getSortedByDatePosts().length; +const pageCount = Math.ceil(totalPosts / postsPerPage); + +const CurrentPostsCount = ({ + startIndex, + endIndex, +}: { + startIndex: number; + endIndex: number; +}) => { + const start = startIndex + 1; + const end = endIndex < totalPosts ? endIndex : totalPosts; + if (start === end) return ({start}); + return ( + + ({start}-{end}) + + ); +}; + +const Pagination = ({ pageIndex }: { pageIndex: number }) => { + const handlePageChange = async (page: number) => { + 'use server'; + redirect(`/posts?page=${page}`); + }; + + return ( +
+ +
+ ); +}; + +export default async function Page(props: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const searchParams = await props.searchParams; + const pageIndex = searchParams.page + ? Number.parseInt(searchParams.page[0] ?? '', 10) - 1 + : 0; + if (pageIndex < 0 || pageIndex >= pageCount) notFound(); + + const startIndex = pageIndex * postsPerPage; + const endIndex = startIndex + postsPerPage; + const posts = getSortedByDatePosts().slice(startIndex, endIndex); + + return ( + <> +
+

+ All {totalPosts} Posts{' '} + +

+
+
+
+ {posts.map((post) => { + const date = new Date(post.data.date).toDateString(); + return ( + + ); + })} +
+
+ {pageCount > 1 && } + + ); +} + +export const generateStaticParams = () => { + const slugs = Array.from({ length: pageCount }, (_, index) => ({ + slug: [(index + 1).toString()], + })); + + return [{ slug: [] }, ...slugs]; +}; + +type Props = { + params: Promise<{ slug: string[] }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +export async function generateMetadata( + props: Props, + parent: ResolvingMetadata, +): Promise { + const params = await props.params; + const searchParams = await props.searchParams; + + const pageIndex = searchParams.page + ? Number.parseInt(searchParams.page as string, 10) + : 1; + + const isFirstPage = pageIndex === 1 || !searchParams.page; + const pageTitle = isFirstPage ? 'Posts' : `Posts - Page ${pageIndex}`; + const canonicalUrl = isFirstPage ? '/posts' : `/posts?page=${pageIndex}`; + + return createMetadata({ + title: pageTitle, + description: `Posts${!isFirstPage ? ` - Page ${pageIndex}` : ''}`, + openGraph: { + url: canonicalUrl, + }, + alternates: { + canonical: canonicalUrl, + }, + }); +} diff --git a/src/app/(home)/tags/[...slug]/page.tsx b/src/app/(home)/tags/[...slug]/page.tsx new file mode 100644 index 0000000..9479705 --- /dev/null +++ b/src/app/(home)/tags/[...slug]/page.tsx @@ -0,0 +1,180 @@ +import { postsPerPage } from '@/app/layout.config'; +import { Icons } from '@/components/icons/icons'; +import { TagJsonLd } from '@/components/json-ld'; +import { NumberedPagination } from '@/components/numbered-pagination'; +import { PostCard } from '@/components/posts/post-card'; +import { Section } from '@/components/section'; +import { createMetadata } from '@/lib/metadata'; +import { getPostsByTag, getTags } from '@/lib/source'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { notFound, redirect } from 'next/navigation'; + +export const dynamicParams = false; + +const totalPosts = (title: string) => getPostsByTag(title).length; +const pageCount = (title: string) => + Math.ceil(totalPosts(title) / postsPerPage); + +const CurrentPostsCount = ({ + startIndex, + endIndex, + tag, +}: { + startIndex: number; + endIndex: number; + tag: string; +}) => { + const total = totalPosts(tag); + const start = startIndex + 1; + const end = endIndex < total ? endIndex : total; + if (start === end) return ({start}); + return ( + + ({start}-{end}) + + ); +}; + +const Header = ({ + tag, + startIndex, + endIndex, +}: { + tag: string; + startIndex: number; + endIndex: number; +}) => ( +
+
+ +

+ {tag} Posts{' '} + +

+
+
+); + +const Pagination = ({ pageIndex, tag }: { pageIndex: number; tag: string }) => { + const handlePageChange = async (page: number) => { + 'use server'; + redirect(`/tags/${tag}?page=${page}`); + }; + + return ( +
+ +
+ ); +}; + +export default async function Page(props: { + params: Promise<{ slug: string[] }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + + const tag = params.slug[0]; + if (!tag) return notFound(); + + const pageIndex = searchParams.page + ? Number.parseInt(searchParams.page[0] ?? '', 10) - 1 + : 0; + + if (pageIndex < 0 || pageIndex >= pageCount(tag)) notFound(); + + const startIndex = pageIndex * postsPerPage; + const endIndex = startIndex + postsPerPage; + const posts = getPostsByTag(tag).slice(startIndex, endIndex); + + return ( + <> +
+
+
+ {posts.map((post) => { + const date = new Date(post.data.date).toDateString(); + return ( + + ); + })} +
+
+ {pageCount(tag) > 1 && } + + + ); +} + +export const generateStaticParams = () => { + const tags = getTags(); + return [ + ...tags.map((tag) => ({ slug: [tag] })), + ...tags.flatMap((tag) => + Array.from({ length: pageCount(tag) }, (_, index) => ({ + slug: [tag, (index + 1).toString()], + })), + ), + ]; +}; + +type Props = { + params: Promise<{ slug: string[] }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +export async function generateMetadata( + props: Props, + parent: ResolvingMetadata, +): Promise { + const params = await props.params; + const searchParams = await props.searchParams; + + const tag = params.slug[0]; + const pageIndex = searchParams.page + ? Number.parseInt(searchParams.page.toString(), 10) + : 1; + + const isFirstPage = pageIndex === 1 || !searchParams.page; + const pageTitle = isFirstPage + ? `${tag} Posts` + : `${tag} Posts - Page ${pageIndex}`; + const canonicalUrl = isFirstPage + ? `/tags/${tag}` + : `/tags/${tag}?page=${pageIndex}`; + + return createMetadata({ + title: pageTitle, + description: `Posts tagged with ${tag}${ + !isFirstPage ? ` - Page ${pageIndex}` : '' + }`, + openGraph: { + url: canonicalUrl, + }, + alternates: { + canonical: canonicalUrl, + }, + }); +} diff --git a/src/app/(home)/tags/page.tsx b/src/app/(home)/tags/page.tsx new file mode 100644 index 0000000..54fb423 --- /dev/null +++ b/src/app/(home)/tags/page.tsx @@ -0,0 +1,57 @@ +import { title as homeTitle } from '@/app/layout.config'; +import { Section } from '@/components/section'; +import { TagCard } from '@/components/tags/tag-card'; +import { createMetadata } from '@/lib/metadata'; +import { getTags } from '@/lib/source'; +import { cn } from '@/lib/utils'; +import type { Metadata } from 'next'; + +export default function Page() { + const tags = getTags(); + + return ( + <> +
+

+ Tags +

+
+
+
+ {tags.map((tag, index) => ( + + ))} + {tags.length % 2 === 1 && ( +
+ )} +
+
+ + ); +} + +export async function generateMetadata(props: { + params: Promise<{ slug?: string[] }>; +}): Promise { + const params = await props.params; + const description = `Explore all the tags on ${homeTitle}.`; + + return createMetadata({ + title: 'Tags', + description, + openGraph: { + url: '/tags', + }, + alternates: { + canonical: '/tags', + }, + }); +} -- cgit v1.2.3