summaryrefslogtreecommitdiff
path: root/src/app/(home)
diff options
context:
space:
mode:
authorBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
committerBertrand Yuan <bert.yuan@outlook.com>2025-12-15 23:48:10 +0800
commit5b7ccf0b671e2999b62befc729a3e517a0433728 (patch)
tree8bf476dc7c75914c221042546840dc76267366df /src/app/(home)
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.
Diffstat (limited to 'src/app/(home)')
-rw-r--r--src/app/(home)/(mdx)/about/page.mdx24
-rw-r--r--src/app/(home)/_components/call-to-action.tsx23
-rw-r--r--src/app/(home)/_components/hero.tsx98
-rw-r--r--src/app/(home)/_components/posts.tsx40
-rw-r--r--src/app/(home)/actions.ts78
-rw-r--r--src/app/(home)/layout.tsx31
-rw-r--r--src/app/(home)/page.tsx29
-rw-r--r--src/app/(home)/posts/[slug]/page.client.tsx57
-rw-r--r--src/app/(home)/posts/[slug]/page.tsx145
-rw-r--r--src/app/(home)/posts/page.tsx133
-rw-r--r--src/app/(home)/tags/[...slug]/page.tsx180
-rw-r--r--src/app/(home)/tags/page.tsx57
12 files changed, 895 insertions, 0 deletions
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';
+
+<MdxLayout title="About" toc={toc} comments={true} slug={"about"}>
+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 `<div>` 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
+</MdxLayout> \ 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 (
+ <Section className='relative grid gap-8 px-4 py-10 sm:grid-cols-2 md:py-14 lg:px-6 lg:py-16'>
+ <div className='max-w-xl space-y-2'>
+ <h2 className='font-semibold text-2xl md:text-3xl lg:text-4xl'>
+ Subscribe to the Newsletter
+ </h2>
+ <p className='text-muted-foreground text-sm md:text-base'>
+ Get the latest articles and updates delivered straight to your inbox.
+ No spam, unsubscribe anytime.
+ </p>
+ </div>
+
+ <div className='flex w-full items-center'>
+ <NewsletterForm />
+ </div>
+ </Section>
+ );
+}
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 (
+ <Section className='relative flex flex-col items-center justify-center gap-6 overflow-hidden bg-dashed px-4 py-16 sm:px-16 sm:py-24 md:py-32'>
+ <motion.div
+ initial={{ opacity: 0 }}
+ animate={{ opacity: 1 }}
+ transition={{
+ duration: 0.4,
+ scale: { type: 'spring', visualDuration: 0.4, bounce: 0.5 },
+ }}
+ whileInView={{ opacity: 1 }}
+ viewport={{ once: true }}
+ className='-z-10 absolute inset-0 h-full w-full'
+ >
+ <Image
+ src={heroImage}
+ alt='Hero Background'
+ height={600}
+ width={704}
+ className='pointer-events-none absolute right-0 bottom-0 h-[900px] w-[1004px] max-w-[1004px] translate-x-1/2 translate-y-1/2 select-none opacity-80 dark:opacity-100'
+ priority
+ />
+ </motion.div>
+ <div className='flex items-center justify-center space-x-2'>
+ <Icons.code className='h-6 w-6 text-primary transition-transform hover:scale-125' />
+ <span className='font-medium text-muted-foreground text-sm'>
+ Full-Stack Developer & Tech Writer
+ </span>
+ </div>
+ <h1 className='max-w-3xl text-center font-bold text-4xl leading-tight tracking-tighter sm:text-5xl md:max-w-4xl md:text-6xl lg:leading-[1.1]'>
+ <Balancer>I'm John Doe , a Full-Stack Developer.</Balancer>
+ </h1>
+ <p className='max-w-xl text-center text-muted-foreground md:max-w-2xl md:text-lg'>
+ <Balancer>
+ 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.
+ </Balancer>
+ </p>
+
+ <div className='flex flex-wrap items-center justify-center gap-4'>
+ <Link
+ className={cn(
+ buttonVariants({
+ variant: 'default',
+ size: 'lg',
+ }),
+ 'group rounded-full bg-primary hover:bg-primary/90',
+ )}
+ href='/posts'
+ >
+ Browse Posts
+ <Icons.arrowUpRight className='group-hover:-rotate-12 ml-2 size-5 transition-transform' />
+ </Link>
+
+ <div className='flex items-center space-x-4'>
+ {navItems
+ .filter((item) => item.type === 'icon')
+ .map((item, i) => (
+ <Link
+ key={i.toString()}
+ href={item.url}
+ className={cn(
+ buttonVariants({
+ variant: 'ghost',
+ size: 'icon',
+ }),
+ 'rounded-full',
+ )}
+ >
+ {item.icon}
+ <span className='sr-only'>{item.text}</span>
+ </Link>
+ ))}
+ </div>
+ </div>
+ </Section>
+ );
+};
+
+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 (
+ <Section>
+ <div className='grid divide-y divide-dashed divide-border/70 text-left dark:divide-border'>
+ {posts.map((post) => {
+ const date = new Date(post.data.date).toDateString();
+ return (
+ <PostCard
+ title={post.data.title}
+ description={post.data.description ?? ''}
+ image={post.data.image}
+ url={post.url}
+ date={date}
+ key={post.url}
+ author={post.data.author}
+ tags={post.data.tags}
+ />
+ );
+ })}
+ <Link
+ href='/posts'
+ className={buttonVariants({
+ variant: 'default',
+ className: 'group rounded-none py-4 sm:py-8',
+ })}
+ >
+ View More
+ <Icons.arrowUpRight className='group-hover:-rotate-12 ml-2 size-5 transition-transform' />
+ </Link>
+ </div>
+ </Section>
+ );
+}
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 (
+ <HomeLayout
+ {...baseOptions}
+ links={linkItems}
+ nav={{
+ component: (
+ <Header
+ finalLinks={getLinks(linkItems, baseOptions.githubUrl)}
+ {...baseOptions}
+ />
+ ),
+ }}
+ className='pt-0'
+ >
+ <main className='flex flex-1 flex-col divide-y divide-dashed divide-border/70 border-border/70 border-dashed sm:border-b dark:divide-border dark:border-border'>
+ {children}
+ <Footer />
+ </main>
+ </HomeLayout>
+ );
+};
+
+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 (
+ <>
+ <Hero />
+ <Section className='py-8 sm:py-16'>
+ <h2 className='text-center font-semibold text-2xl sm:text-3xl md:text-4xl lg:text-5xl'>
+ <span className='inline-flex items-center gap-3'>
+ Posts{' '}
+ <Icons.posts className='size-10 fill-fd-primary/30 text-fd-primary transition-transform hover:rotate-12 hover:scale-125' />
+ </span>
+ </h2>
+ </Section>
+ <Separator />
+ <Posts posts={posts} />
+ <Separator />
+ <CTA />
+ </>
+ );
+}
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<ShareIconHandle>(null);
+ const [_, copyToClipboard] = useCopyToClipboard();
+
+ const onClick = async (): Promise<void> => {
+ await copyToClipboard(`${window.location.origin}${url}`);
+ toast.success('Copied to clipboard!', {
+ icon: <Icons.copied className='size-4' />,
+ description: 'The post link has been copied to your clipboard.',
+ });
+ };
+
+ return (
+ <Button
+ className={cn('group gap-2')}
+ variant={'secondary'}
+ onClick={onClick}
+ onMouseEnter={() => iconRef.current?.startAnimation?.()}
+ onMouseLeave={() => iconRef.current?.stopAnimation?.()}
+ >
+ <ShareIcon className='size-4 hover:bg-transparent' ref={iconRef} />
+ Share Post
+ </Button>
+ );
+}
+
+export function PostComments({
+ slug,
+ className,
+}: { slug: string; className?: string }) {
+ return (
+ <Comments
+ page={slug}
+ className={cn('w-full', className)}
+ auth={{
+ type: 'api',
+ signIn: () => {
+ 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 (
+ <Section className='p-4 lg:p-6'>
+ <div
+ className={cn(
+ 'flex flex-col items-start justify-center gap-4 py-8 md:gap-6',
+ 'sm:items-center sm:rounded-lg sm:border sm:bg-muted/70 sm:px-8 sm:py-20 sm:shadow-xs sm:dark:bg-muted',
+ )}
+ >
+ <div className='flex flex-col gap-2 sm:text-center md:gap-4'>
+ <h1 className='max-w-4xl font-bold text-3xl leading-tight tracking-tight sm:text-4xl sm:leading-tight md:text-5xl md:leading-tight'>
+ <Balancer>{page.data.title}</Balancer>
+ </h1>
+ <p className='mx-auto max-w-4xl'>
+ <Balancer>{page.data.description}</Balancer>
+ </p>
+ </div>
+ <div className='flex flex-wrap gap-2'>
+ {tags?.map((tag) => (
+ <TagCard name={tag} key={tag} className=' border border-border ' />
+ ))}
+ </div>
+ </div>
+ </Section>
+ );
+}
+
+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 (
+ <>
+ <Header page={page} tags={tags} />
+
+ <Section className='h-full' sectionClassName='flex flex-1'>
+ <article className='flex min-h-full flex-col lg:flex-row'>
+ <div className='flex flex-1 flex-col gap-4'>
+ <InlineTOC
+ items={toc}
+ className='rounded-none border-0 border-border/70 border-b border-dashed dark:border-border'
+ />
+ <div className='prose min-w-0 flex-1 px-4'>
+ <Mdx
+ components={{
+ ...defaultMdxComponents,
+ File,
+ Files,
+ Folder,
+ Tabs,
+ Tab,
+ }}
+ />
+ </div>
+ <PostComments
+ slug={params.slug}
+ className='[&_form>div]:!rounded-none rounded-none border-0 border-border/70 border-t border-dashed dark:border-border'
+ />
+ </div>
+ <div className='flex flex-col gap-4 p-4 text-sm lg:sticky lg:top-[4rem] lg:h-[calc(100vh-4rem)] lg:w-[250px] lg:self-start lg:overflow-y-auto lg:border-border/70 lg:border-l lg:border-dashed lg:dark:border-border'>
+ <div>
+ <p className='mb-1 text-fd-muted-foreground'>Written by</p>
+ <p className='font-medium'>{page.data.author}</p>
+ </div>
+ <div>
+ <p className='mb-1 text-fd-muted-foreground text-sm'>
+ Created At
+ </p>
+ <p className='font-medium'>
+ {new Date(page.data.date ?? page.file.name).toDateString()}
+ </p>
+ </div>
+ {lastUpdate && (
+ <div>
+ <p className='mb-1 text-fd-muted-foreground text-sm'>
+ Updated At
+ </p>
+ <p className='font-medium'>{lastUpdate.toDateString()}</p>
+ </div>
+ )}
+ <Share url={page.url} />
+ </div>
+ </article>
+ </Section>
+ <PostJsonLd page={page} />
+ </>
+ );
+}
+
+export async function generateMetadata(props: {
+ params: Promise<{ slug: string }>;
+}): Promise<Metadata> {
+ 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 <span>({start})</span>;
+ return (
+ <span>
+ ({start}-{end})
+ </span>
+ );
+};
+
+const Pagination = ({ pageIndex }: { pageIndex: number }) => {
+ const handlePageChange = async (page: number) => {
+ 'use server';
+ redirect(`/posts?page=${page}`);
+ };
+
+ return (
+ <Section className='bg-dashed'>
+ <NumberedPagination
+ currentPage={pageIndex + 1}
+ totalPages={pageCount}
+ paginationItemsToDisplay={5}
+ onPageChange={handlePageChange}
+ />
+ </Section>
+ );
+};
+
+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 (
+ <>
+ <Section className='p-4 lg:p-6'>
+ <h1 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
+ All {totalPosts} Posts{' '}
+ <CurrentPostsCount startIndex={startIndex} endIndex={endIndex} />
+ </h1>
+ </Section>
+ <Section className='h-full' sectionClassName='flex flex-1'>
+ <div className='grid divide-y divide-dashed divide-border/70 text-left dark:divide-border'>
+ {posts.map((post) => {
+ const date = new Date(post.data.date).toDateString();
+ return (
+ <PostCard
+ title={post.data.title}
+ description={post.data.description ?? ''}
+ image={post.data.image}
+ url={post.url}
+ date={date}
+ key={post.url}
+ author={post.data.author}
+ tags={post.data.tags}
+ />
+ );
+ })}
+ </div>
+ </Section>
+ {pageCount > 1 && <Pagination pageIndex={pageIndex} />}
+ </>
+ );
+}
+
+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<Metadata> {
+ 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 <span>({start})</span>;
+ return (
+ <span>
+ ({start}-{end})
+ </span>
+ );
+};
+
+const Header = ({
+ tag,
+ startIndex,
+ endIndex,
+}: {
+ tag: string;
+ startIndex: number;
+ endIndex: number;
+}) => (
+ <Section className='p-4 lg:p-6'>
+ <div className='flex items-center gap-2'>
+ <Icons.tag
+ size={20}
+ className='text-muted-foreground transition-transform hover:rotate-12 hover:scale-125'
+ />
+ <h1 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
+ {tag} <span className='text-muted-foreground'>Posts</span>{' '}
+ <CurrentPostsCount
+ startIndex={startIndex}
+ endIndex={endIndex}
+ tag={tag}
+ />
+ </h1>
+ </div>
+ </Section>
+);
+
+const Pagination = ({ pageIndex, tag }: { pageIndex: number; tag: string }) => {
+ const handlePageChange = async (page: number) => {
+ 'use server';
+ redirect(`/tags/${tag}?page=${page}`);
+ };
+
+ return (
+ <Section className='bg-dashed'>
+ <NumberedPagination
+ currentPage={pageIndex + 1}
+ totalPages={pageCount(tag)}
+ paginationItemsToDisplay={5}
+ onPageChange={handlePageChange}
+ />
+ </Section>
+ );
+};
+
+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 (
+ <>
+ <Header tag={tag} startIndex={startIndex} endIndex={endIndex} />
+ <Section className='h-full' sectionClassName='flex flex-1'>
+ <div className='grid divide-y divide-dashed divide-border/70 text-left dark:divide-border'>
+ {posts.map((post) => {
+ const date = new Date(post.data.date).toDateString();
+ return (
+ <PostCard
+ title={post.data.title}
+ description={post.data.description ?? ''}
+ image={post.data.image}
+ url={post.url}
+ date={date}
+ key={post.url}
+ author={post.data.author}
+ tags={post.data.tags}
+ />
+ );
+ })}
+ </div>
+ </Section>
+ {pageCount(tag) > 1 && <Pagination pageIndex={pageIndex} tag={tag} />}
+ <TagJsonLd tag={tag} />
+ </>
+ );
+}
+
+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<Metadata> {
+ 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 (
+ <>
+ <Section className='p-4 lg:p-6'>
+ <h1 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
+ Tags
+ </h1>
+ </Section>
+ <Section className='h-full' sectionClassName='flex flex-1'>
+ <div className='grid grid-cols-1 divide-y divide-dashed divide-border/70 sm:grid-cols-2 lg:grid-cols-4 dark:divide-border'>
+ {tags.map((tag, index) => (
+ <TagCard
+ key={tag}
+ displayCount={true}
+ name={tag}
+ className={cn(
+ 'items-center justify-start gap-2 rounded-none border-r-0 bg-card/50 p-6 last:border-border/70 last:border-b last:border-dashed hover:bg-card/80 last:dark:border-border',
+ tags.at(index - 1) && 'border-l',
+ )}
+ />
+ ))}
+ {tags.length % 2 === 1 && (
+ <div className='size-full border-border/70 border-dashed bg-dashed sm:border-b sm:border-l dark:border-border' />
+ )}
+ </div>
+ </Section>
+ </>
+ );
+}
+
+export async function generateMetadata(props: {
+ params: Promise<{ slug?: string[] }>;
+}): Promise<Metadata> {
+ const params = await props.params;
+ const description = `Explore all the tags on ${homeTitle}.`;
+
+ return createMetadata({
+ title: 'Tags',
+ description,
+ openGraph: {
+ url: '/tags',
+ },
+ alternates: {
+ canonical: '/tags',
+ },
+ });
+}