diff options
| author | Bertrand Yuan <bert.yuan@outlook.com> | 2025-12-16 00:25:04 +0800 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-12-16 00:25:04 +0800 |
| commit | 39c83fbb69ef06d2d56790d75abc254ba7e34394 (patch) | |
| tree | dd006593448c3500bdcb414af3b4656f7a7683d4 /src/app/(home) | |
| parent | 48b07bc308a35734a6a7a305c8fdccbfa47de7d8 (diff) | |
| parent | 785371bb3eccca455e5ce5fccbe9b6e3752a03f6 (diff) | |
Merge pull request #1 from bertyuan/feat-introduce-payloadv1.0
Feat: introduce payload
Diffstat (limited to 'src/app/(home)')
| -rw-r--r-- | src/app/(home)/(mdx)/about/page.mdx | 24 | ||||
| -rw-r--r-- | src/app/(home)/_components/call-to-action.tsx | 23 | ||||
| -rw-r--r-- | src/app/(home)/_components/hero.tsx | 98 | ||||
| -rw-r--r-- | src/app/(home)/_components/posts.tsx | 40 | ||||
| -rw-r--r-- | src/app/(home)/actions.ts | 78 | ||||
| -rw-r--r-- | src/app/(home)/layout.tsx | 31 | ||||
| -rw-r--r-- | src/app/(home)/page.tsx | 29 | ||||
| -rw-r--r-- | src/app/(home)/posts/[slug]/page.client.tsx | 57 | ||||
| -rw-r--r-- | src/app/(home)/posts/[slug]/page.tsx | 145 | ||||
| -rw-r--r-- | src/app/(home)/posts/page.tsx | 133 | ||||
| -rw-r--r-- | src/app/(home)/tags/[...slug]/page.tsx | 180 | ||||
| -rw-r--r-- | src/app/(home)/tags/page.tsx | 57 |
12 files changed, 0 insertions, 895 deletions
diff --git a/src/app/(home)/(mdx)/about/page.mdx b/src/app/(home)/(mdx)/about/page.mdx deleted file mode 100644 index 675f3a8..0000000 --- a/src/app/(home)/(mdx)/about/page.mdx +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index b75298e..0000000 --- a/src/app/(home)/_components/call-to-action.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 8ac251b..0000000 --- a/src/app/(home)/_components/hero.tsx +++ /dev/null @@ -1,98 +0,0 @@ -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 deleted file mode 100644 index 8c8dc33..0000000 --- a/src/app/(home)/_components/posts.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index fdb16ca..0000000 --- a/src/app/(home)/actions.ts +++ /dev/null @@ -1,78 +0,0 @@ -'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 deleted file mode 100644 index bd641df..0000000 --- a/src/app/(home)/layout.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index da7da0f..0000000 --- a/src/app/(home)/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 7a97f56..0000000 --- a/src/app/(home)/posts/[slug]/page.client.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'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 deleted file mode 100644 index 15a6bfd..0000000 --- a/src/app/(home)/posts/[slug]/page.tsx +++ /dev/null @@ -1,145 +0,0 @@ -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 deleted file mode 100644 index fd0f912..0000000 --- a/src/app/(home)/posts/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 9479705..0000000 --- a/src/app/(home)/tags/[...slug]/page.tsx +++ /dev/null @@ -1,180 +0,0 @@ -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 deleted file mode 100644 index 54fb423..0000000 --- a/src/app/(home)/tags/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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', - }, - }); -} |
