diff options
Diffstat (limited to 'src/app/(main)/(home)')
| -rw-r--r-- | src/app/(main)/(home)/(mdx)/about/page.mdx | 24 | ||||
| -rw-r--r-- | src/app/(main)/(home)/_components/call-to-action.tsx | 23 | ||||
| -rw-r--r-- | src/app/(main)/(home)/_components/hero.tsx | 98 | ||||
| -rw-r--r-- | src/app/(main)/(home)/_components/posts.tsx | 89 | ||||
| -rw-r--r-- | src/app/(main)/(home)/actions.ts | 78 | ||||
| -rw-r--r-- | src/app/(main)/(home)/layout.tsx | 31 | ||||
| -rw-r--r-- | src/app/(main)/(home)/page.tsx | 34 | ||||
| -rw-r--r-- | src/app/(main)/(home)/posts/[slug]/page.client.tsx | 57 | ||||
| -rw-r--r-- | src/app/(main)/(home)/posts/[slug]/page.tsx | 266 | ||||
| -rw-r--r-- | src/app/(main)/(home)/posts/page.tsx | 203 | ||||
| -rw-r--r-- | src/app/(main)/(home)/tags/[...slug]/page.tsx | 180 | ||||
| -rw-r--r-- | src/app/(main)/(home)/tags/page.tsx | 57 |
12 files changed, 1140 insertions, 0 deletions
diff --git a/src/app/(main)/(home)/(mdx)/about/page.mdx b/src/app/(main)/(home)/(mdx)/about/page.mdx new file mode 100644 index 0000000..675f3a8 --- /dev/null +++ b/src/app/(main)/(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/(main)/(home)/_components/call-to-action.tsx b/src/app/(main)/(home)/_components/call-to-action.tsx new file mode 100644 index 0000000..b75298e --- /dev/null +++ b/src/app/(main)/(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/(main)/(home)/_components/hero.tsx b/src/app/(main)/(home)/_components/hero.tsx new file mode 100644 index 0000000..04371ca --- /dev/null +++ b/src/app/(main)/(home)/_components/hero.tsx @@ -0,0 +1,98 @@ +import { baseOptions, linkItems } from '@/app/(main)/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/(main)/(home)/_components/posts.tsx b/src/app/(main)/(home)/_components/posts.tsx new file mode 100644 index 0000000..00ada0c --- /dev/null +++ b/src/app/(main)/(home)/_components/posts.tsx @@ -0,0 +1,89 @@ +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 type { BlogPost } from '@/lib/payload-posts'; +import Link from 'next/link'; + +// 统一的文章数据格式 +interface UnifiedPost { + title: string; + description: string; + image?: string | null; + url: string; + date: string; + author: string; + tags?: string[]; +} + +// 将 MDX Page 转换为统一格式 +function transformMdxPost(post: Page): UnifiedPost { + return { + title: post.data.title, + description: post.data.description ?? '', + image: post.data.image, + url: post.url, + date: new Date(post.data.date).toDateString(), + author: post.data.author, + tags: post.data.tags, + }; +} + +// 将 Payload BlogPost 转换为统一格式 +function transformPayloadPost(post: BlogPost): UnifiedPost { + return { + title: post.title, + description: post.description, + image: post.image, + url: post.url, + date: post.date.toDateString(), + author: post.author, + tags: post.tags, + }; +} + +interface PostsProps { + mdxPosts?: Page[]; + payloadPosts?: BlogPost[]; +} + +export default function Posts({ mdxPosts = [], payloadPosts = [] }: PostsProps) { + // 转换并合并所有文章 + const allPosts: UnifiedPost[] = [ + ...mdxPosts.map(transformMdxPost), + ...payloadPosts.map(transformPayloadPost), + ]; + + // 按日期排序 + allPosts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + return ( + <Section> + <div className='grid divide-y divide-dashed divide-border/70 text-left dark:divide-border'> + {allPosts.map((post) => ( + <PostCard + title={post.title} + description={post.description} + image={post.image} + url={post.url} + date={post.date} + key={post.url} + author={post.author} + tags={post.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/(main)/(home)/actions.ts b/src/app/(main)/(home)/actions.ts new file mode 100644 index 0000000..fdb16ca --- /dev/null +++ b/src/app/(main)/(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/(main)/(home)/layout.tsx b/src/app/(main)/(home)/layout.tsx new file mode 100644 index 0000000..bd641df --- /dev/null +++ b/src/app/(main)/(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/(main)/(home)/page.tsx b/src/app/(main)/(home)/page.tsx new file mode 100644 index 0000000..a94193c --- /dev/null +++ b/src/app/(main)/(home)/page.tsx @@ -0,0 +1,34 @@ +import Hero from '@/app/(main)/(home)/_components/hero'; +import Posts from '@/app/(main)/(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 { getPublishedPosts } from '@/lib/payload-posts'; +import { CTA } from './_components/call-to-action'; + +export default async function Home() { + // 获取 MDX 文章(保留原有功能) + // const mdxPosts = getSortedByDatePosts().slice(0, 3); + + // 获取 Payload CMS 文章 + const { posts: payloadPosts } = await getPublishedPosts({ limit: 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 mdxPosts={[]} payloadPosts={payloadPosts} /> + <Separator /> + <CTA /> + </> + ); +} diff --git a/src/app/(main)/(home)/posts/[slug]/page.client.tsx b/src/app/(main)/(home)/posts/[slug]/page.client.tsx new file mode 100644 index 0000000..7a97f56 --- /dev/null +++ b/src/app/(main)/(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/(main)/(home)/posts/[slug]/page.tsx b/src/app/(main)/(home)/posts/[slug]/page.tsx new file mode 100644 index 0000000..fa096b6 --- /dev/null +++ b/src/app/(main)/(home)/posts/[slug]/page.tsx @@ -0,0 +1,266 @@ +import { + PostComments, + Share, +} from '@/app/(main)/(home)/posts/[slug]/page.client'; +import { PostJsonLd } from '@/components/json-ld'; +import { RichText } from '@/components/rich-text'; +import { Section } from '@/components/section'; +import { TagCard } from '@/components/tags/tag-card'; +import { createMetadata } from '@/lib/metadata'; +import { metadataImage } from '@/lib/metadata-image'; +import { + getPostBySlug, + getAllPostSlugs, + type BlogPost, +} from '@/lib/payload-posts'; +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 '@/app/(main)/layout.config'; + +// MDX 文章 Header +function MdxHeader(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> + ); +} + +// Payload 文章 Header +function PayloadHeader(props: { post: BlogPost }) { + const { post } = 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>{post.title}</Balancer> + </h1> + <p className="mx-auto max-w-4xl"> + <Balancer>{post.description}</Balancer> + </p> + </div> + <div className="flex flex-wrap gap-2"> + {post.tags?.map((tag) => ( + <TagCard name={tag} key={tag} className=" border border-border " /> + ))} + </div> + </div> + </Section> + ); +} + +// MDX 文章内容 +function MdxContent({ page }: { page: MDXPage }) { + const { body: Mdx, toc, tags, lastModified } = page.data; + const lastUpdate = lastModified ? new Date(lastModified) : undefined; + + return ( + <> + <MdxHeader 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={page.slugs[0] ?? ''} + 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} /> + </> + ); +} + +// Payload 文章内容 +function PayloadContent({ post }: { post: BlogPost }) { + return ( + <> + <PayloadHeader post={post} /> + + <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"> + <RichText + content={post.content as Record<string, unknown>} + className="flex-1 px-4" + enableProse={true} + /> + <PostComments + slug={post.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">{post.author}</p> + </div> + <div> + <p className="mb-1 text-fd-muted-foreground text-sm">Created At</p> + <p className="font-medium">{post.date.toDateString()}</p> + </div> + <div> + <p className="mb-1 text-fd-muted-foreground text-sm">Updated At</p> + <p className="font-medium">{post.updatedAt.toDateString()}</p> + </div> + <Share url={post.url} /> + </div> + </article> + </Section> + </> + ); +} + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + + // 先尝试获取 MDX 文章 + const mdxPage = getPost([params.slug]); + + if (mdxPage) { + return <MdxContent page={mdxPage} />; + } + + // 再尝试获取 Payload 文章 + const payloadPost = await getPostBySlug(params.slug); + + if (payloadPost) { + return <PayloadContent post={payloadPost} />; + } + + // 都找不到则 404 + notFound(); +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise<Metadata> { + const params = await props.params; + + // 先尝试 MDX 文章 + const mdxPage = getPost([params.slug]); + + if (mdxPage) { + const title = mdxPage.data.title; + const description = mdxPage.data.description ?? homeDescription; + + return createMetadata( + metadataImage.withImage(mdxPage.slugs, { + title, + description, + openGraph: { + url: `/posts/${mdxPage.slugs.join('/')}`, + }, + alternates: { + canonical: mdxPage.url, + }, + }) + ); + } + + // 再尝试 Payload 文章 + const payloadPost = await getPostBySlug(params.slug); + + if (payloadPost) { + return createMetadata({ + title: payloadPost.title, + description: payloadPost.description || homeDescription, + openGraph: { + url: `/posts/${payloadPost.slug}`, + }, + alternates: { + canonical: `/posts/${payloadPost.slug}`, + }, + }); + } + + return {}; +} + +export async function generateStaticParams(): Promise<{ slug: string }[]> { + // MDX 文章的 slugs + const mdxSlugs = getPosts() + .map((page) => page.slugs[0]) + .filter((slug): slug is string => !!slug) + .map((slug) => ({ slug })); + + // Payload 文章的 slugs + const payloadSlugs = await getAllPostSlugs(); + const payloadParams = payloadSlugs.map((slug) => ({ slug })); + + return [...mdxSlugs, ...payloadParams]; +} diff --git a/src/app/(main)/(home)/posts/page.tsx b/src/app/(main)/(home)/posts/page.tsx new file mode 100644 index 0000000..00e51c4 --- /dev/null +++ b/src/app/(main)/(home)/posts/page.tsx @@ -0,0 +1,203 @@ +import { postsPerPage } from '@/app/(main)/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 { getPublishedPosts, type BlogPost } from '@/lib/payload-posts'; +import { getSortedByDatePosts, type Page } from '@/lib/source'; +import type { Metadata, ResolvingMetadata } from 'next'; +import { notFound, redirect } from 'next/navigation'; + +// 统一的文章类型 +interface UnifiedPost { + id: string; + title: string; + description: string; + image?: string; + url: string; + date: Date; + author: string; + tags?: string[]; + source: 'mdx' | 'payload'; +} + +// 将 MDX 文章转换为统一格式 +function transformMdxPost(post: Page): UnifiedPost { + return { + id: `mdx-${post.url}`, + title: post.data.title, + description: post.data.description ?? '', + image: post.data.image, + url: post.url, + date: new Date(post.data.date), + author: post.data.author, + tags: post.data.tags, + source: 'mdx', + }; +} + +// 将 Payload 文章转换为统一格式 +function transformPayloadPost(post: BlogPost): UnifiedPost { + return { + id: `payload-${post.id}`, + title: post.title, + description: post.description, + image: post.image, + url: post.url, + date: post.date, + author: post.author, + tags: post.tags, + source: 'payload', + }; +} + +// 获取所有文章(合并 MDX 和 Payload) +async function getAllPosts(): Promise<UnifiedPost[]> { + // 获取 MDX 文章 + const mdxPosts = getSortedByDatePosts().map(transformMdxPost); + + // 获取 Payload 文章(获取全部,用于分页计算) + const { posts: payloadPosts } = await getPublishedPosts({ limit: 1000 }); + const transformedPayloadPosts = payloadPosts.map(transformPayloadPost); + + // 合并并按日期排序 + const allPosts = [...mdxPosts, ...transformedPayloadPosts]; + allPosts.sort((a, b) => b.date.getTime() - a.date.getTime()); + + return allPosts; +} + +const CurrentPostsCount = ({ + startIndex, + endIndex, + totalPosts, +}: { + startIndex: number; + endIndex: number; + totalPosts: 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, + pageCount, +}: { + pageIndex: number; + pageCount: 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 allPosts = await getAllPosts(); + const totalPosts = allPosts.length; + const pageCount = Math.ceil(totalPosts / postsPerPage); + + const pageIndex = searchParams.page + ? Number.parseInt( + Array.isArray(searchParams.page) + ? searchParams.page[0] ?? '' + : searchParams.page, + 10 + ) - 1 + : 0; + + if (pageIndex < 0 || (pageCount > 0 && pageIndex >= pageCount)) notFound(); + + const startIndex = pageIndex * postsPerPage; + const endIndex = startIndex + postsPerPage; + const posts = allPosts.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} + totalPosts={totalPosts} + /> + </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 = post.date.toDateString(); + return ( + <PostCard + title={post.title} + description={post.description} + image={post.image} + url={post.url} + date={date} + key={post.id} + author={post.author} + tags={post.tags} + /> + ); + })} + </div> + </Section> + {pageCount > 1 && <Pagination pageIndex={pageIndex} pageCount={pageCount} />} + </> + ); +} + +type Props = { + params: Promise<{ slug: string[] }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +export async function generateMetadata( + props: Props, + parent: ResolvingMetadata +): Promise<Metadata> { + 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/(main)/(home)/tags/[...slug]/page.tsx b/src/app/(main)/(home)/tags/[...slug]/page.tsx new file mode 100644 index 0000000..c1ce96b --- /dev/null +++ b/src/app/(main)/(home)/tags/[...slug]/page.tsx @@ -0,0 +1,180 @@ +import { postsPerPage } from '@/app/(main)/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/(main)/(home)/tags/page.tsx b/src/app/(main)/(home)/tags/page.tsx new file mode 100644 index 0000000..9138fde --- /dev/null +++ b/src/app/(main)/(home)/tags/page.tsx @@ -0,0 +1,57 @@ +import { title as homeTitle } from '@/app/(main)/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', + }, + }); +} |
