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 | |
| parent | 48b07bc308a35734a6a7a305c8fdccbfa47de7d8 (diff) | |
| parent | 785371bb3eccca455e5ce5fccbe9b6e3752a03f6 (diff) | |
Merge pull request #1 from bertyuan/feat-introduce-payloadv1.0
Feat: introduce payload
Diffstat (limited to 'src')
64 files changed, 2558 insertions, 411 deletions
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/(auth)/login/page.tsx b/src/app/(main)/(auth)/login/page.tsx index 2469097..3ff59d3 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(main)/(auth)/login/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { baseOptions, linkItems } from '@/app/layout.config'; +import { baseOptions, linkItems } from '@/app/(main)/layout.config'; import { Icons } from '@/components/icons/icons'; import { Header } from '@/components/sections/header'; import { Button } from '@/components/ui/button'; diff --git a/src/app/(home)/(mdx)/about/page.mdx b/src/app/(main)/(home)/(mdx)/about/page.mdx index 675f3a8..675f3a8 100644 --- a/src/app/(home)/(mdx)/about/page.mdx +++ b/src/app/(main)/(home)/(mdx)/about/page.mdx diff --git a/src/app/(home)/_components/call-to-action.tsx b/src/app/(main)/(home)/_components/call-to-action.tsx index b75298e..b75298e 100644 --- a/src/app/(home)/_components/call-to-action.tsx +++ b/src/app/(main)/(home)/_components/call-to-action.tsx diff --git a/src/app/(home)/_components/hero.tsx b/src/app/(main)/(home)/_components/hero.tsx index 8ac251b..04371ca 100644 --- a/src/app/(home)/_components/hero.tsx +++ b/src/app/(main)/(home)/_components/hero.tsx @@ -1,4 +1,4 @@ -import { baseOptions, linkItems } from '@/app/layout.config'; +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'; @@ -8,7 +8,7 @@ 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'; +import heroImage from '../../../../../public/images/gradient-noise-purple-azure-light.png'; const Hero = () => { const links = getLinks(linkItems, baseOptions.githubUrl); diff --git a/src/app/(home)/_components/posts.tsx b/src/app/(main)/(home)/_components/posts.tsx index 8c8dc33..0eacce1 100644 --- a/src/app/(home)/_components/posts.tsx +++ b/src/app/(main)/(home)/_components/posts.tsx @@ -2,28 +2,29 @@ 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'; -export default function Posts({ posts }: { posts: Page[] }) { +interface PostsProps { + posts: BlogPost[]; +} + +export default function Posts({ posts }: PostsProps) { 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} - /> - ); - })} + {posts.map((post) => ( + <PostCard + title={post.title} + description={post.description} + image={post.image} + url={post.url} + date={post.date.toDateString()} + key={post.id} + author={post.author} + tags={post.tags} + /> + ))} <Link href='/posts' className={buttonVariants({ diff --git a/src/app/(home)/actions.ts b/src/app/(main)/(home)/actions.ts index fdb16ca..5b0c456 100644 --- a/src/app/(home)/actions.ts +++ b/src/app/(main)/(home)/actions.ts @@ -1,8 +1,7 @@ 'use server'; -import { getContact, sendWelcomeEmail, updateContact } from '@/lib/resend'; +import { getContact, 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'; @@ -59,12 +58,12 @@ export const subscribeUser = actionClient ); } - const posts = getSortedByDatePosts(); - await sendWelcomeEmail({ - posts, - to: email, - firstName: firstName || 'there', - }); + // const posts = getSortedByDatePosts(); + // await sendWelcomeEmail({ + // posts, + // to: email, + // firstName: firstName || 'there', + // }); return { success: true, diff --git a/src/app/(home)/layout.tsx b/src/app/(main)/(home)/layout.tsx index bd641df..bd641df 100644 --- a/src/app/(home)/layout.tsx +++ b/src/app/(main)/(home)/layout.tsx diff --git a/src/app/(home)/page.tsx b/src/app/(main)/(home)/page.tsx index da7da0f..0718da9 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(main)/(home)/page.tsx @@ -1,13 +1,13 @@ -import Hero from '@/app/(home)/_components/hero'; -import Posts from '@/app/(home)/_components/posts'; +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 function Home() { - const posts = getSortedByDatePosts().slice(0, 3); +export default async function Home() { + const { posts } = await getPublishedPosts({ limit: 3 }); return ( <> @@ -15,7 +15,7 @@ export default function Home() { <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{' '} + Posts <Icons.posts className='size-10 fill-fd-primary/30 text-fd-primary transition-transform hover:rotate-12 hover:scale-125' /> </span> </h2> diff --git a/src/app/(home)/posts/[slug]/page.client.tsx b/src/app/(main)/(home)/posts/[slug]/page.client.tsx index 7a97f56..7a97f56 100644 --- a/src/app/(home)/posts/[slug]/page.client.tsx +++ b/src/app/(main)/(home)/posts/[slug]/page.client.tsx 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..5960f06 --- /dev/null +++ b/src/app/(main)/(home)/posts/[slug]/page.tsx @@ -0,0 +1,129 @@ +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 { + getPostBySlug, + getAllPostSlugs, + type BlogPost, +} from '@/lib/payload-posts'; +import { cn } from '@/lib/utils'; +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'; + +function PostHeader(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> + ); +} + +function PostContent({ post }: { post: BlogPost }) { + return ( + <> + <PostHeader 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> + <PostJsonLd post={post} /> + </> + ); +} + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + const post = await getPostBySlug(params.slug); + + if (!post) { + notFound(); + } + + return <PostContent post={post} />; +} + +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise<Metadata> { + const params = await props.params; + const post = await getPostBySlug(params.slug); + + if (!post) { + return {}; + } + + return createMetadata({ + title: post.title, + description: post.description || homeDescription, + openGraph: { + url: `/posts/${post.slug}`, + images: post.image ? [{ url: post.image }] : undefined, + }, + alternates: { + canonical: `/posts/${post.slug}`, + }, + }); +} + +export async function generateStaticParams(): Promise<{ slug: string }[]> { + const slugs = await getAllPostSlugs(); + return slugs.map((slug) => ({ slug })); +} diff --git a/src/app/(home)/posts/page.tsx b/src/app/(main)/(home)/posts/page.tsx index fd0f912..40ebcda 100644 --- a/src/app/(home)/posts/page.tsx +++ b/src/app/(main)/(home)/posts/page.tsx @@ -1,23 +1,20 @@ -import { postsPerPage } from '@/app/layout.config'; +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 { getSortedByDatePosts } from '@/lib/source'; +import { getPublishedPosts } from '@/lib/payload-posts'; 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, + totalPosts, }: { startIndex: number; endIndex: number; + totalPosts: number; }) => { const start = startIndex + 1; const end = endIndex < totalPosts ? endIndex : totalPosts; @@ -29,7 +26,13 @@ const CurrentPostsCount = ({ ); }; -const Pagination = ({ pageIndex }: { pageIndex: number }) => { +const Pagination = ({ + pageIndex, + pageCount, +}: { + pageIndex: number; + pageCount: number; +}) => { const handlePageChange = async (page: number) => { 'use server'; redirect(`/posts?page=${page}`); @@ -51,55 +54,63 @@ 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 + ? Number.parseInt( + Array.isArray(searchParams.page) + ? searchParams.page[0] ?? '' + : searchParams.page, + 10 + ) - 1 : 0; - if (pageIndex < 0 || pageIndex >= pageCount) notFound(); + + // 获取文章(带分页) + const { posts, totalDocs, totalPages } = await getPublishedPosts({ + limit: postsPerPage, + page: pageIndex + 1, + }); + + if (pageIndex < 0 || (totalPages > 0 && pageIndex >= totalPages)) notFound(); const startIndex = pageIndex * postsPerPage; - const endIndex = startIndex + postsPerPage; - const posts = getSortedByDatePosts().slice(startIndex, endIndex); + const endIndex = startIndex + posts.length; 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} /> + All {totalDocs} Posts{' '} + <CurrentPostsCount + startIndex={startIndex} + endIndex={endIndex} + totalPosts={totalDocs} + /> </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(); + const date = post.date.toDateString(); return ( <PostCard - title={post.data.title} - description={post.data.description ?? ''} - image={post.data.image} + title={post.title} + description={post.description} + image={post.image} url={post.url} date={date} - key={post.url} - author={post.data.author} - tags={post.data.tags} + key={post.id} + author={post.author} + tags={post.tags} /> ); })} </div> </Section> - {pageCount > 1 && <Pagination pageIndex={pageIndex} />} + {totalPages > 1 && <Pagination pageIndex={pageIndex} pageCount={totalPages} />} </> ); } -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 }>; @@ -107,9 +118,8 @@ type Props = { export async function generateMetadata( props: Props, - parent: ResolvingMetadata, + parent: ResolvingMetadata ): Promise<Metadata> { - const params = await props.params; const searchParams = await props.searchParams; const pageIndex = searchParams.page diff --git a/src/app/(home)/tags/[...slug]/page.tsx b/src/app/(main)/(home)/tags/[...slug]/page.tsx index 9479705..71615cb 100644 --- a/src/app/(home)/tags/[...slug]/page.tsx +++ b/src/app/(main)/(home)/tags/[...slug]/page.tsx @@ -1,32 +1,25 @@ -import { postsPerPage } from '@/app/layout.config'; +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 { getPostsByTag, getAllTags } from '@/lib/payload-posts'; 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, + totalPosts, }: { startIndex: number; endIndex: number; - tag: string; + totalPosts: number; }) => { - const total = totalPosts(tag); const start = startIndex + 1; - const end = endIndex < total ? endIndex : total; + const end = endIndex < totalPosts ? endIndex : totalPosts; if (start === end) return <span>({start})</span>; return ( <span> @@ -39,10 +32,12 @@ const Header = ({ tag, startIndex, endIndex, + totalPosts, }: { tag: string; startIndex: number; endIndex: number; + totalPosts: number; }) => ( <Section className='p-4 lg:p-6'> <div className='flex items-center gap-2'> @@ -55,14 +50,14 @@ const Header = ({ <CurrentPostsCount startIndex={startIndex} endIndex={endIndex} - tag={tag} + totalPosts={totalPosts} /> </h1> </div> </Section> ); -const Pagination = ({ pageIndex, tag }: { pageIndex: number; tag: string }) => { +const Pagination = ({ pageIndex, tag, totalPages }: { pageIndex: number; tag: string; totalPages: number }) => { const handlePageChange = async (page: number) => { 'use server'; redirect(`/tags/${tag}?page=${page}`); @@ -72,7 +67,7 @@ const Pagination = ({ pageIndex, tag }: { pageIndex: number; tag: string }) => { <Section className='bg-dashed'> <NumberedPagination currentPage={pageIndex + 1} - totalPages={pageCount(tag)} + totalPages={totalPages} paginationItemsToDisplay={5} onPageChange={handlePageChange} /> @@ -91,54 +86,56 @@ export default async function Page(props: { if (!tag) return notFound(); const pageIndex = searchParams.page - ? Number.parseInt(searchParams.page[0] ?? '', 10) - 1 + ? Number.parseInt( + Array.isArray(searchParams.page) + ? searchParams.page[0] ?? '' + : searchParams.page, + 10 + ) - 1 : 0; - if (pageIndex < 0 || pageIndex >= pageCount(tag)) notFound(); + const { posts, totalDocs, totalPages } = await getPostsByTag(tag, { + limit: postsPerPage, + page: pageIndex + 1, + }); + + if (pageIndex < 0 || (totalPages > 0 && pageIndex >= totalPages)) notFound(); const startIndex = pageIndex * postsPerPage; - const endIndex = startIndex + postsPerPage; - const posts = getPostsByTag(tag).slice(startIndex, endIndex); + const endIndex = startIndex + posts.length; return ( <> - <Header tag={tag} startIndex={startIndex} endIndex={endIndex} /> + <Header tag={tag} startIndex={startIndex} endIndex={endIndex} totalPosts={totalDocs} /> <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(); + const date = post.date.toDateString(); return ( <PostCard - title={post.data.title} - description={post.data.description ?? ''} - image={post.data.image} + title={post.title} + description={post.description} + image={post.image} url={post.url} date={date} - key={post.url} - author={post.data.author} - tags={post.data.tags} + key={post.id} + author={post.author} + tags={post.tags} /> ); })} </div> </Section> - {pageCount(tag) > 1 && <Pagination pageIndex={pageIndex} tag={tag} />} + {totalPages > 1 && <Pagination pageIndex={pageIndex} tag={tag} totalPages={totalPages} />} <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()], - })), - ), - ]; -}; +export async function generateStaticParams() { + const tags = await getAllTags(); + return tags.map((item) => ({ slug: [item.tag] })); +} type Props = { params: Promise<{ slug: string[] }>; @@ -154,7 +151,12 @@ export async function generateMetadata( const tag = params.slug[0]; const pageIndex = searchParams.page - ? Number.parseInt(searchParams.page.toString(), 10) + ? Number.parseInt( + Array.isArray(searchParams.page) + ? searchParams.page[0] ?? '' + : searchParams.page, + 10 + ) : 1; const isFirstPage = pageIndex === 1 || !searchParams.page; diff --git a/src/app/(home)/tags/page.tsx b/src/app/(main)/(home)/tags/page.tsx index 54fb423..6db13fc 100644 --- a/src/app/(home)/tags/page.tsx +++ b/src/app/(main)/(home)/tags/page.tsx @@ -1,13 +1,13 @@ -import { title as homeTitle } from '@/app/layout.config'; +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 { getAllTags } from '@/lib/payload-posts'; import { cn } from '@/lib/utils'; import type { Metadata } from 'next'; -export default function Page() { - const tags = getTags(); +export default async function Page() { + const tags = await getAllTags(); return ( <> @@ -18,11 +18,12 @@ export default function Page() { </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) => ( + {tags.map((item, index) => ( <TagCard - key={tag} + key={item.tag} displayCount={true} - name={tag} + name={item.tag} + count={item.count} 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', diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/(main)/api/auth/[...all]/route.ts index 677b24c..677b24c 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/(main)/api/auth/[...all]/route.ts diff --git a/src/app/api/comments/[...comment]/route.ts b/src/app/(main)/api/comments/[...comment]/route.ts index 1da87db..1da87db 100644 --- a/src/app/api/comments/[...comment]/route.ts +++ b/src/app/(main)/api/comments/[...comment]/route.ts diff --git a/src/app/(main)/api/search/route.ts b/src/app/(main)/api/search/route.ts new file mode 100644 index 0000000..cc2a1c8 --- /dev/null +++ b/src/app/(main)/api/search/route.ts @@ -0,0 +1,20 @@ +import { getPublishedPosts } from '@/lib/payload-posts'; +import { createSearchAPI } from 'fumadocs-core/search/server'; + +// 动态生成搜索索引 +export async function GET(request: Request) { + const { posts } = await getPublishedPosts({ limit: 1000 }); + + const indexes = posts.map((post) => ({ + title: post.title, + description: post.description, + id: post.url, + url: post.url, + })); + + const searchAPI = createSearchAPI('advanced', { + indexes, + }); + + return searchAPI.GET(request); +} diff --git a/src/app/banner.png/fonts/geist-regular-otf.json b/src/app/(main)/banner.png/fonts/geist-regular-otf.json index f220c87..f220c87 100644 --- a/src/app/banner.png/fonts/geist-regular-otf.json +++ b/src/app/(main)/banner.png/fonts/geist-regular-otf.json diff --git a/src/app/banner.png/fonts/geist-semibold-otf.json b/src/app/(main)/banner.png/fonts/geist-semibold-otf.json index c119360..c119360 100644 --- a/src/app/banner.png/fonts/geist-semibold-otf.json +++ b/src/app/(main)/banner.png/fonts/geist-semibold-otf.json diff --git a/src/app/banner.png/fonts/geistmono-regular-otf.json b/src/app/(main)/banner.png/fonts/geistmono-regular-otf.json index f4200df..f4200df 100644 --- a/src/app/banner.png/fonts/geistmono-regular-otf.json +++ b/src/app/(main)/banner.png/fonts/geistmono-regular-otf.json diff --git a/src/app/banner.png/og.tsx b/src/app/(main)/banner.png/og.tsx index 1a520c0..1a520c0 100644 --- a/src/app/banner.png/og.tsx +++ b/src/app/(main)/banner.png/og.tsx diff --git a/src/app/banner.png/route.tsx b/src/app/(main)/banner.png/route.tsx index 1cd53ac..d3bfdc8 100644 --- a/src/app/banner.png/route.tsx +++ b/src/app/(main)/banner.png/route.tsx @@ -1,4 +1,4 @@ -import { generateOGImage } from '@/app/banner.png/og'; +import { generateOGImage } from '@/app/(main)/banner.png/og'; async function loadAssets(): Promise< { name: string; data: Buffer; weight: 400 | 600; style: 'normal' }[] diff --git a/src/app/layout.client.tsx b/src/app/(main)/layout.client.tsx index 35726ba..35726ba 100644 --- a/src/app/layout.client.tsx +++ b/src/app/(main)/layout.client.tsx diff --git a/src/app/layout.config.tsx b/src/app/(main)/layout.config.tsx index f9efebb..f9efebb 100644 --- a/src/app/layout.config.tsx +++ b/src/app/(main)/layout.config.tsx diff --git a/src/app/layout.tsx b/src/app/(main)/layout.tsx index c7d2aaf..c9e7dee 100644 --- a/src/app/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -5,9 +5,9 @@ import type { ReactNode } from 'react'; import '@/styles/globals.css'; import 'katex/dist/katex.css'; import { baseUrl } from '@/lib/constants'; -import { Body } from './layout.client'; -import { description as homeDescription } from './layout.config'; -import { Provider } from './provider'; +import { Body } from '@/app/(main)/layout.client'; +import { description as homeDescription } from '@/app/(main)/layout.config'; +import { Provider } from '@/app/(main)/provider'; const geistSans = Geist({ variable: '--font-geist-sans', diff --git a/src/app/not-found.tsx b/src/app/(main)/not-found.tsx index ecec57a..ecec57a 100644 --- a/src/app/not-found.tsx +++ b/src/app/(main)/not-found.tsx diff --git a/src/app/og/[...slug]/fonts/geist-regular-otf.json b/src/app/(main)/og/[...slug]/fonts/geist-regular-otf.json index f220c87..f220c87 100644 --- a/src/app/og/[...slug]/fonts/geist-regular-otf.json +++ b/src/app/(main)/og/[...slug]/fonts/geist-regular-otf.json diff --git a/src/app/og/[...slug]/fonts/geist-semibold-otf.json b/src/app/(main)/og/[...slug]/fonts/geist-semibold-otf.json index c119360..c119360 100644 --- a/src/app/og/[...slug]/fonts/geist-semibold-otf.json +++ b/src/app/(main)/og/[...slug]/fonts/geist-semibold-otf.json diff --git a/src/app/og/[...slug]/fonts/geistmono-regular-otf.json b/src/app/(main)/og/[...slug]/fonts/geistmono-regular-otf.json index f4200df..f4200df 100644 --- a/src/app/og/[...slug]/fonts/geistmono-regular-otf.json +++ b/src/app/(main)/og/[...slug]/fonts/geistmono-regular-otf.json diff --git a/src/app/og/[...slug]/og.tsx b/src/app/(main)/og/[...slug]/og.tsx index 5754e96..5754e96 100644 --- a/src/app/og/[...slug]/og.tsx +++ b/src/app/(main)/og/[...slug]/og.tsx diff --git a/src/app/og/[...slug]/route.tsx b/src/app/(main)/og/[...slug]/route.tsx index 8738616..77ae7f8 100644 --- a/src/app/og/[...slug]/route.tsx +++ b/src/app/(main)/og/[...slug]/route.tsx @@ -1,6 +1,7 @@ -import { generateOGImage } from '@/app/og/[...slug]/og'; -import { metadataImage } from '@/lib/metadata-image'; +import { generateOGImage } from '@/app/(main)/og/[...slug]/og'; +import { getPostBySlug, getAllPostSlugs } from '@/lib/payload-posts'; import type { ImageResponse } from 'next/og'; +import { notFound } from 'next/navigation'; async function loadAssets(): Promise< { name: string; data: Buffer; weight: 400 | 600; style: 'normal' }[] @@ -39,20 +40,33 @@ async function loadAssets(): Promise< ]; } -export const GET = metadataImage.createAPI( - async (page): Promise<ImageResponse> => { - const [fonts] = await Promise.all([loadAssets()]); - - return generateOGImage({ - title: page.data.title, - description: page.data.description, - fonts, - }); - }, -); - -export function generateStaticParams(): { - slug: string[]; -}[] { - return metadataImage.generateParams(); +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string[] }> } +): Promise<ImageResponse> { + const { slug } = await params; + const postSlug = slug[0]; + + if (!postSlug) { + notFound(); + } + + const post = await getPostBySlug(postSlug); + + if (!post) { + notFound(); + } + + const fonts = await loadAssets(); + + return generateOGImage({ + title: post.title, + description: post.description, + fonts, + }); +} + +export async function generateStaticParams(): Promise<{ slug: string[] }[]> { + const slugs = await getAllPostSlugs(); + return slugs.map((slug) => ({ slug: [slug, 'image.png'] })); } diff --git a/src/app/provider.tsx b/src/app/(main)/provider.tsx index 085bf50..085bf50 100644 --- a/src/app/provider.tsx +++ b/src/app/(main)/provider.tsx diff --git a/src/app/rss.xml/route.ts b/src/app/(main)/rss.xml/route.ts index 6a3acf6..3507948 100644 --- a/src/app/rss.xml/route.ts +++ b/src/app/(main)/rss.xml/route.ts @@ -1,10 +1,10 @@ -import { description, title } from '@/app/layout.config'; -import { owner } from '@/app/layout.config'; +import { description, title } from '@/app/(main)/layout.config'; +import { owner } from '@/app/(main)/layout.config'; import { baseUrl } from '@/lib/constants'; -import { getPosts } from '@/lib/source'; +import { getPublishedPosts } from '@/lib/payload-posts'; import { Feed } from 'feed'; -export const dynamic = 'force-static'; +export const dynamic = 'force-dynamic'; const escapeForXML = (str: string) => { return str @@ -15,8 +15,8 @@ const escapeForXML = (str: string) => { .replace(/'/g, '''); }; -export const GET = () => { - const feed = createFeed(); +export const GET = async () => { + const feed = await createFeed(); return new Response(feed.atom1(), { headers: { @@ -25,7 +25,7 @@ export const GET = () => { }); }; -function createFeed(): Feed { +async function createFeed(): Promise<Feed> { const feed = new Feed({ title, description, @@ -39,24 +39,24 @@ function createFeed(): Feed { updated: new Date(), }); - const posts = getPosts(); + const { posts } = await getPublishedPosts({ limit: 1000 }); + for (const post of posts) { feed.addItem({ - title: post.data.title, - description: post.data.description, + title: post.title, + description: post.description, link: new URL(post.url, baseUrl).href, - image: { - title: post.data.title, - type: 'image/png', - url: escapeForXML( - new URL(`/og/${post.slugs.join('/')}/image.png`, baseUrl.href).href, - ), - }, - date: post.data.date, + image: post.image + ? { + title: post.title, + type: 'image/png', + url: escapeForXML(new URL(post.image, baseUrl.href).href), + } + : undefined, + date: post.date, author: [ { - name: post.data.author, - // link: new URL('/about', baseUrl).href, + name: post.author, }, ], }); diff --git a/src/app/(payload)/admin/[[...segments]]/not-found.tsx b/src/app/(payload)/admin/[[...segments]]/not-found.tsx new file mode 100644 index 0000000..b28df09 --- /dev/null +++ b/src/app/(payload)/admin/[[...segments]]/not-found.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next'; + +import config from '@payload-config'; +import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'; +import { importMap } from '../importMap'; + +type Args = { + params: Promise<{ + segments: string[]; + }>; + searchParams: Promise<{ + [key: string]: string | string[]; + }>; +}; + +export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> => + generatePageMetadata({ config, params, searchParams }); + +const NotFound = ({ params, searchParams }: Args) => + NotFoundPage({ config, importMap, params, searchParams }); + +export default NotFound; diff --git a/src/app/(payload)/admin/[[...segments]]/page.tsx b/src/app/(payload)/admin/[[...segments]]/page.tsx new file mode 100644 index 0000000..f04f258 --- /dev/null +++ b/src/app/(payload)/admin/[[...segments]]/page.tsx @@ -0,0 +1,24 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import type { Metadata } from 'next'; + +import config from '@payload-config'; +import { RootPage, generatePageMetadata } from '@payloadcms/next/views'; +import { importMap } from '../importMap'; + +type Args = { + params: Promise<{ + segments: string[]; + }>; + searchParams: Promise<{ + [key: string]: string | string[]; + }>; +}; + +export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> => + generatePageMetadata({ config, params, searchParams }); + +const Page = ({ params, searchParams }: Args) => + RootPage({ config, params, searchParams, importMap }); + +export default Page; diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js new file mode 100644 index 0000000..5bc8ec3 --- /dev/null +++ b/src/app/(payload)/admin/importMap.js @@ -0,0 +1,49 @@ +import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc' +import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' + +export const importMap = { + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e, + "@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 +} diff --git a/src/app/(payload)/api/[...slug]/route.ts b/src/app/(payload)/api/[...slug]/route.ts new file mode 100644 index 0000000..c3de612 --- /dev/null +++ b/src/app/(payload)/api/[...slug]/route.ts @@ -0,0 +1,20 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config' +import '@payloadcms/next/css' +import { + REST_DELETE, + REST_GET, + REST_OPTIONS, + REST_PATCH, + REST_POST, + REST_PUT, +} from '@payloadcms/next/routes' + +export const GET = REST_GET(config) +export const POST = REST_POST(config) +export const DELETE = REST_DELETE(config) +export const PATCH = REST_PATCH(config) + +export const PUT = REST_PUT(config) +export const OPTIONS = REST_OPTIONS(config) diff --git a/src/app/(payload)/api/graphql-playground/route.ts b/src/app/(payload)/api/graphql-playground/route.ts new file mode 100644 index 0000000..c14156d --- /dev/null +++ b/src/app/(payload)/api/graphql-playground/route.ts @@ -0,0 +1,6 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config'; +import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'; + +export const GET = GRAPHQL_PLAYGROUND_GET(config); diff --git a/src/app/(payload)/api/graphql/route.ts b/src/app/(payload)/api/graphql/route.ts new file mode 100644 index 0000000..65fcf23 --- /dev/null +++ b/src/app/(payload)/api/graphql/route.ts @@ -0,0 +1,6 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config'; +import { GRAPHQL_POST } from '@payloadcms/next/routes'; + +export const POST = GRAPHQL_POST(config); diff --git a/src/app/(payload)/custom.scss b/src/app/(payload)/custom.scss new file mode 100644 index 0000000..f38c2f0 --- /dev/null +++ b/src/app/(payload)/custom.scss @@ -0,0 +1 @@ +/* Add custom Payload admin styles here */ diff --git a/src/app/(payload)/layout.tsx b/src/app/(payload)/layout.tsx new file mode 100644 index 0000000..f14247a --- /dev/null +++ b/src/app/(payload)/layout.tsx @@ -0,0 +1,30 @@ +/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ +/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ +import config from '@payload-config'; +import '@payloadcms/next/css'; +import type { ServerFunctionClient } from 'payload'; +import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'; +import React from 'react'; +import { importMap } from './admin/importMap.js'; +import './custom.scss'; + +type Args = { + children: React.ReactNode; +}; + +const serverFunction: ServerFunctionClient = async function (args) { + 'use server'; + return handleServerFunctions({ + ...args, + config, + importMap, + }); +}; + +const Layout = ({ children }: Args) => ( + <RootLayout config={config} importMap={importMap} serverFunction={serverFunction}> + {children} + </RootLayout> +); + +export default Layout; diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts deleted file mode 100644 index 3c99f5c..0000000 --- a/src/app/api/search/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getPosts } from '@/lib/source'; -import { createSearchAPI } from 'fumadocs-core/search/server'; - -export const { GET } = createSearchAPI('advanced', { - indexes: getPosts().map((page) => ({ - title: page.data.title, - structuredData: page.data.structuredData, - id: page.url, - url: page.url, - })), -}); diff --git a/src/app/icon.png b/src/app/icon.png Binary files differdeleted file mode 100644 index 7532d9b..0000000 --- a/src/app/icon.png +++ /dev/null diff --git a/src/components/docs.tsx b/src/components/docs.tsx index 038a52b..0dec2dd 100644 --- a/src/components/docs.tsx +++ b/src/components/docs.tsx @@ -1,5 +1,5 @@ import type { PageTree } from 'fumadocs-core/server'; -import { cn } from 'fumadocs-ui/components/api'; +import { cn } from '@/lib/utils'; import { type SidebarOptions, checkPageTree, diff --git a/src/components/json-ld.tsx b/src/components/json-ld.tsx index cd30274..58cb0ba 100644 --- a/src/components/json-ld.tsx +++ b/src/components/json-ld.tsx @@ -1,33 +1,30 @@ -import { title as homeTitle } from '@/app/layout.config'; -import { owner } from '@/app/layout.config'; +import { title as homeTitle } from '@/app/(main)/layout.config'; +import { owner } from '@/app/(main)/layout.config'; import { baseUrl } from '@/lib/constants'; -import type { Post } from '@/lib/source'; +import type { BlogPost } from '@/lib/payload-posts'; import type { BlogPosting, BreadcrumbList, Graph } from 'schema-dts'; -export const PostJsonLd = ({ page }: { page: Post }) => { - if (!page) { +export const PostJsonLd = ({ post }: { post: BlogPost }) => { + if (!post) { return null; } - const url = new URL(page.url, baseUrl.href).href; + const url = new URL(post.url, baseUrl.href).href; - const post: BlogPosting = { + const blogPosting: BlogPosting = { '@type': 'BlogPosting', - headline: page.data.title, - description: page.data.description, - image: new URL(`/og/${page.slugs.join('/')}/image.png`, baseUrl.href).href, - datePublished: new Date(page.data.date).toISOString(), - dateModified: page.data.lastModified - ? new Date(page.data.lastModified).toISOString() - : undefined, + headline: post.title, + description: post.description, + image: post.image ? new URL(post.image, baseUrl.href).href : undefined, + datePublished: post.date.toISOString(), + dateModified: post.updatedAt.toISOString(), mainEntityOfPage: { '@type': 'WebPage', '@id': url, }, author: { '@type': 'Person', - name: page.data.author, - // url: 'https://techwithanirudh.com/', + name: post.author, }, publisher: { '@type': 'Person', @@ -54,7 +51,7 @@ export const PostJsonLd = ({ page }: { page: Post }) => { { '@type': 'ListItem', position: 3, - name: page.data.title, + name: post.title, item: url, }, ], @@ -62,7 +59,7 @@ export const PostJsonLd = ({ page }: { page: Post }) => { const graph: Graph = { '@context': 'https://schema.org', - '@graph': [post, breadcrumbList], + '@graph': [blogPosting, breadcrumbList], }; return ( diff --git a/src/components/mdx-layout.tsx b/src/components/mdx-layout.tsx index bc68a93..f984dfc 100644 --- a/src/components/mdx-layout.tsx +++ b/src/components/mdx-layout.tsx @@ -1,4 +1,4 @@ -import { PostComments } from '@/app/(home)/posts/[slug]/page.client'; +import { PostComments } from '@/app/(main)/(home)/posts/[slug]/page.client'; import type { TOCItemType } from 'fumadocs-core/server'; import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; import type { ReactNode } from 'react'; diff --git a/src/components/newsletter-form.tsx b/src/components/newsletter-form.tsx index 7dd0cc0..83a9bee 100644 --- a/src/components/newsletter-form.tsx +++ b/src/components/newsletter-form.tsx @@ -18,7 +18,7 @@ import { useForm } from 'react-hook-form'; import { Alert, AlertTitle } from '@/components/ui/alert'; -import { subscribeUser } from '@/app/(home)/actions'; +import { subscribeUser } from '@/app/(main)/(home)/actions'; import { Icons } from '@/components/icons/icons'; export const NewsletterForm = () => { diff --git a/src/components/rich-text/index.tsx b/src/components/rich-text/index.tsx new file mode 100644 index 0000000..556942c --- /dev/null +++ b/src/components/rich-text/index.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/utils' +import React from 'react' +import { serializeLexical } from './serialize' + +type Props = { + className?: string + content: Record<string, unknown> + enableProse?: boolean +} + +export function RichText({ + className, + content, + enableProse = true, +}: Props) { + if (!content) { + return null + } + + return ( + <div + className={cn( + { + 'prose min-w-0 dark:prose-invert': enableProse, + }, + className + )} + > + {content && + !Array.isArray(content) && + typeof content === 'object' && + 'root' in content && + serializeLexical({ + nodes: (content.root as { children: unknown[] })?.children as Parameters< + typeof serializeLexical + >[0]['nodes'], + })} + </div> + ) +} + +export default RichText diff --git a/src/components/rich-text/node-format.ts b/src/components/rich-text/node-format.ts new file mode 100644 index 0000000..84e7d7e --- /dev/null +++ b/src/components/rich-text/node-format.ts @@ -0,0 +1,11 @@ +// From Lexical: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts + +// Text node formatting +export const IS_BOLD = 1 +export const IS_ITALIC = 1 << 1 +export const IS_STRIKETHROUGH = 1 << 2 +export const IS_UNDERLINE = 1 << 3 +export const IS_CODE = 1 << 4 +export const IS_SUBSCRIPT = 1 << 5 +export const IS_SUPERSCRIPT = 1 << 6 +export const IS_HIGHLIGHT = 1 << 7 diff --git a/src/components/rich-text/serialize.tsx b/src/components/rich-text/serialize.tsx new file mode 100644 index 0000000..d75c40f --- /dev/null +++ b/src/components/rich-text/serialize.tsx @@ -0,0 +1,201 @@ +import React, { Fragment, type JSX } from 'react' +import Link from 'next/link' +import { + IS_BOLD, + IS_CODE, + IS_ITALIC, + IS_STRIKETHROUGH, + IS_SUBSCRIPT, + IS_SUPERSCRIPT, + IS_UNDERLINE, +} from './node-format' + +// Lexical 节点类型 +interface LexicalNode { + type: string + format?: number + text?: string + tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'ul' | 'ol' + listType?: 'bullet' | 'number' | 'check' + checked?: boolean + value?: number + children?: LexicalNode[] + fields?: { + linkType?: 'internal' | 'custom' + url?: string + newTab?: boolean + doc?: { + value?: { + slug?: string + } + relationTo?: string + } + } + language?: string + version?: number +} + +type Props = { + nodes: LexicalNode[] +} + +export function serializeLexical({ nodes }: Props): JSX.Element { + return ( + <Fragment> + {nodes?.map((node, index): JSX.Element | null => { + if (node == null) { + return null + } + + if (node.type === 'text') { + let text = <React.Fragment key={index}>{node.text}</React.Fragment> + const format = node.format || 0 + + if (format & IS_BOLD) { + text = <strong key={index}>{text}</strong> + } + if (format & IS_ITALIC) { + text = <em key={index}>{text}</em> + } + if (format & IS_STRIKETHROUGH) { + text = ( + <span key={index} style={{ textDecoration: 'line-through' }}> + {text} + </span> + ) + } + if (format & IS_UNDERLINE) { + text = ( + <span key={index} style={{ textDecoration: 'underline' }}> + {text} + </span> + ) + } + if (format & IS_CODE) { + text = <code key={index}>{node.text}</code> + } + if (format & IS_SUBSCRIPT) { + text = <sub key={index}>{text}</sub> + } + if (format & IS_SUPERSCRIPT) { + text = <sup key={index}>{text}</sup> + } + + return text + } + + // 处理子节点 + const serializedChildrenFn = (node: LexicalNode): JSX.Element | null => { + if (node.children == null) { + return null + } else { + // 处理 checkbox list + if (node?.type === 'list' && node?.listType === 'check') { + for (const item of node.children) { + if ('checked' in item) { + if (!item?.checked) { + item.checked = false + } + } + } + } + return serializeLexical({ nodes: node.children }) + } + } + + const serializedChildren = 'children' in node ? serializedChildrenFn(node) : '' + + switch (node.type) { + case 'linebreak': { + return <br key={index} /> + } + case 'paragraph': { + return <p key={index}>{serializedChildren}</p> + } + case 'heading': { + const Tag = node?.tag || 'h2' + return <Tag key={index}>{serializedChildren}</Tag> + } + case 'list': { + const Tag = node?.tag || 'ul' + return <Tag key={index}>{serializedChildren}</Tag> + } + case 'listitem': { + if (node?.checked != null) { + return ( + <li + aria-checked={node.checked ? 'true' : 'false'} + key={index} + role="checkbox" + tabIndex={-1} + value={node?.value} + > + <input + type="checkbox" + checked={node.checked} + readOnly + className="mr-2" + /> + {serializedChildren} + </li> + ) + } else { + return ( + <li key={index} value={node?.value}> + {serializedChildren} + </li> + ) + } + } + case 'quote': { + return <blockquote key={index}>{serializedChildren}</blockquote> + } + case 'link': { + const fields = node.fields + + if (fields?.linkType === 'internal' && fields?.doc?.value?.slug) { + const href = + fields.doc.relationTo === 'posts' + ? `/posts/${fields.doc.value.slug}` + : `/${fields.doc.value.slug}` + + return ( + <Link key={index} href={href}> + {serializedChildren} + </Link> + ) + } + + return ( + <a + key={index} + href={fields?.url || '#'} + target={fields?.newTab ? '_blank' : undefined} + rel={fields?.newTab ? 'noopener noreferrer' : undefined} + > + {serializedChildren} + </a> + ) + } + case 'code': { + // 代码块 + return ( + <pre key={index} className="overflow-x-auto"> + <code>{serializedChildren}</code> + </pre> + ) + } + case 'horizontalrule': { + return <hr key={index} /> + } + default: + // 如果有子节点,递归渲染 + if (node.children) { + return <Fragment key={index}>{serializedChildren}</Fragment> + } + return null + } + })} + </Fragment> + ) +} diff --git a/src/components/sections/footer.tsx b/src/components/sections/footer.tsx index 8d0d876..a982810 100644 --- a/src/components/sections/footer.tsx +++ b/src/components/sections/footer.tsx @@ -1,18 +1,19 @@ -import { baseOptions, linkItems, postsPerPage } from '@/app/layout.config'; +import { baseOptions, linkItems, postsPerPage } from '@/app/(main)/layout.config'; import { InlineLink } from '@/components/inline-link'; -import { getSortedByDatePosts, getTags } from '@/lib/source'; +import { getPublishedPosts, getAllTags } from '@/lib/payload-posts'; import { cn } from '@/lib/utils'; import { getLinks } from 'fumadocs-ui/layouts/shared'; import { ActiveLink } from '../active-link'; -export function Footer() { +export async function Footer() { const links = getLinks(linkItems, baseOptions.githubUrl); const navItems = links.filter((item) => ['nav', 'all'].includes(item.on ?? 'all'), ); - const posts = getSortedByDatePosts(); - const tags = getTags(); + // 从 Payload 获取文章和标签 + const { posts } = await getPublishedPosts({ limit: postsPerPage }); + const tags = await getAllTags(); return ( <footer className={cn('flex flex-col gap-4')}> @@ -54,7 +55,7 @@ export function Footer() { {posts.slice(0, postsPerPage).map((post, i) => ( <li key={post.url}> <ActiveLink key={i.toString()} href={post.url}> - {post.data.title} + {post.title} </ActiveLink> </li> ))} @@ -65,10 +66,10 @@ export function Footer() { <p className='font-medium text-foreground'>Tags</p> <ul className='flex flex-col gap-3'> - {tags.slice(0, postsPerPage).map((name, i) => ( - <li key={`/tags/${name}`}> - <ActiveLink key={i.toString()} href={`/tags/${name}`}> - <span className='capitalize'>{name}</span> + {tags.slice(0, postsPerPage).map((item, i) => ( + <li key={`/tags/${item.tag}`}> + <ActiveLink key={i.toString()} href={`/tags/${item.tag}`}> + <span className='capitalize'>{item.tag}</span> </ActiveLink> </li> ))} @@ -96,17 +97,6 @@ export function Footer() { </ul> </div> </div> - {/* <Design /> */} </footer> ); } - -function Design() { - return ( - <div className='footer'> - <span className='footer-text font-mono'>john•doe</span> - <div className='footer-grid' /> - <div className='footer-gradient' /> - </div> - ); -} diff --git a/src/components/sections/header/menu.tsx b/src/components/sections/header/menu.tsx index 57ddf12..7eb6644 100644 --- a/src/components/sections/header/menu.tsx +++ b/src/components/sections/header/menu.tsx @@ -2,7 +2,7 @@ import { cva } from 'class-variance-authority'; import Link from 'fumadocs-core/link'; -import { cn } from 'fumadocs-ui/components/api'; +import { cn } from '@/lib/utils'; import { buttonVariants } from 'fumadocs-ui/components/ui/button'; import { NavigationMenuContent, diff --git a/src/components/sections/header/navbar.tsx b/src/components/sections/header/navbar.tsx index 25850a0..bac7d3d 100644 --- a/src/components/sections/header/navbar.tsx +++ b/src/components/sections/header/navbar.tsx @@ -1,7 +1,7 @@ 'use client'; import Link, { type LinkProps } from 'fumadocs-core/link'; -import { cn } from 'fumadocs-ui/components/api'; +import { cn } from '@/lib/utils'; import { NavigationMenu, NavigationMenuLink, diff --git a/src/components/tags/tag-card.tsx b/src/components/tags/tag-card.tsx index a71e4c4..447c73e 100644 --- a/src/components/tags/tag-card.tsx +++ b/src/components/tags/tag-card.tsx @@ -1,19 +1,18 @@ import { Icons } from '@/components/icons/icons'; -import { getPostsByTag } from '@/lib/source'; import { cn } from '@/lib/utils'; import Link from 'next/link'; export const TagCard = ({ name, displayCount = false, + count, className = '', }: { name: string; displayCount?: boolean; + count?: number; className?: string; }) => { - const posts = getPostsByTag(name); - return ( <Link href={`/tags/${name}`} @@ -27,8 +26,8 @@ export const TagCard = ({ className='my-auto text-muted-foreground transition-transform group-hover:rotate-12' /> <span className='text-card-foreground'>{name}</span> - {displayCount && ( - <span className='ml-auto text-muted-foreground'>({posts.length})</span> + {displayCount && count !== undefined && ( + <span className='ml-auto text-muted-foreground'>({count})</span> )} </Link> ); diff --git a/src/lib/metadata-image.ts b/src/lib/metadata-image.ts deleted file mode 100644 index f2b91b6..0000000 --- a/src/lib/metadata-image.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { source } from '@/lib/source'; -import { createMetadataImage } from 'fumadocs-core/server'; - -export const metadataImage = createMetadataImage({ - source, - imageRoute: 'og', -}); diff --git a/src/lib/payload-posts.ts b/src/lib/payload-posts.ts new file mode 100644 index 0000000..7cf1a56 --- /dev/null +++ b/src/lib/payload-posts.ts @@ -0,0 +1,192 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import type { Media } from '@/../payload-types' + +// Payload 文章类型 +export interface PayloadPost { + id: number + title: string + slug: string + description?: string + content: unknown // Lexical 富文本内容 + featuredImage?: Media | number + author?: string + tags?: { tag: string; id?: string }[] + status: 'draft' | 'published' + publishedAt?: string + createdAt: string + updatedAt: string +} + +// 转换后的文章类型(用于前端展示) +export interface BlogPost { + id: number + title: string + slug: string + url: string + description: string + content: unknown + image?: string + author: string + tags: string[] + date: Date + createdAt: Date + updatedAt: Date +} + +// 获取 Payload 实例 +async function getPayloadClient() { + return getPayload({ config }) +} + +// 将 Payload 文章转换为博客文章格式 +function transformPost(post: PayloadPost): BlogPost { + // 处理封面图片 URL + let imageUrl: string | undefined + if (post.featuredImage && typeof post.featuredImage === 'object') { + imageUrl = post.featuredImage.url || undefined + } + + // 处理标签数组 + const tags = post.tags?.map((t) => t.tag).filter(Boolean) || [] + + // 处理日期 + const date = post.publishedAt ? new Date(post.publishedAt) : new Date(post.createdAt) + + return { + id: post.id, + title: post.title, + slug: post.slug, + url: `/posts/${post.slug}`, + description: post.description || '', + content: post.content, + image: imageUrl, + author: post.author || 'Admin', + tags, + date, + createdAt: new Date(post.createdAt), + updatedAt: new Date(post.updatedAt), + } +} + +// 获取所有已发布的文章(按发布时间排序) +export async function getPublishedPosts(options?: { + limit?: number + page?: number +}): Promise<{ posts: BlogPost[]; totalDocs: number; totalPages: number }> { + const payload = await getPayloadClient() + + const result = await payload.find({ + collection: 'posts', + where: { + status: { equals: 'published' }, + }, + sort: '-publishedAt', + limit: options?.limit || 10, + page: options?.page || 1, + depth: 1, // 获取关联的 media 数据 + }) + + return { + posts: result.docs.map((doc) => transformPost(doc as unknown as PayloadPost)), + totalDocs: result.totalDocs, + totalPages: result.totalPages, + } +} + +// 根据 slug 获取单篇文章 +export async function getPostBySlug(slug: string): Promise<BlogPost | null> { + const payload = await getPayloadClient() + + const result = await payload.find({ + collection: 'posts', + where: { + slug: { equals: slug }, + status: { equals: 'published' }, + }, + limit: 1, + depth: 1, + }) + + if (result.docs.length === 0) { + return null + } + + return transformPost(result.docs[0] as unknown as PayloadPost) +} + +// 获取所有已发布文章的 slug(用于静态生成) +export async function getAllPostSlugs(): Promise<string[]> { + const payload = await getPayloadClient() + + const result = await payload.find({ + collection: 'posts', + where: { + status: { equals: 'published' }, + }, + limit: 1000, + depth: 0, + }) + + return result.docs.map((doc) => (doc as unknown as PayloadPost).slug) +} + +// 根据标签获取文章 +export async function getPostsByTag( + tag: string, + options?: { limit?: number; page?: number } +): Promise<{ posts: BlogPost[]; totalDocs: number; totalPages: number }> { + const payload = await getPayloadClient() + + const result = await payload.find({ + collection: 'posts', + where: { + and: [ + { status: { equals: 'published' } }, + { 'tags.tag': { equals: tag } }, + ], + }, + sort: '-publishedAt', + limit: options?.limit || 10, + page: options?.page || 1, + depth: 1, + }) + + return { + posts: result.docs.map((doc) => transformPost(doc as unknown as PayloadPost)), + totalDocs: result.totalDocs, + totalPages: result.totalPages, + } +} + +// 获取所有标签 +export async function getAllTags(): Promise<{ tag: string; count: number }[]> { + const payload = await getPayloadClient() + + const result = await payload.find({ + collection: 'posts', + where: { + status: { equals: 'published' }, + }, + limit: 1000, + depth: 0, + }) + + // 统计标签 + const tagCounts = new Map<string, number>() + + for (const doc of result.docs) { + const post = doc as unknown as PayloadPost + if (post.tags) { + for (const { tag } of post.tags) { + if (tag) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1) + } + } + } + } + + return Array.from(tagCounts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count) +} diff --git a/src/lib/source.ts b/src/lib/source.ts deleted file mode 100644 index 34bc7ac..0000000 --- a/src/lib/source.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { loader } from 'fumadocs-core/source'; -import type { InferMetaType, InferPageType } from 'fumadocs-core/source'; -import { createMDXSource } from 'fumadocs-mdx'; -import { blog } from '.source'; - -export const source = loader({ - baseUrl: '/posts', - source: createMDXSource(blog), -}); -export const { getPage: getPost, getPages: getPosts, pageTree } = source; - -export type Post = ReturnType<typeof getPost>; - -const posts = getPosts(); - -export const getSortedByDatePosts = () => - posts.toSorted((a, b) => b.data.date.getTime() - a.data.date.getTime()); - -export const getTags = () => { - const tagSet = new Set<string>(); - - for (const post of posts) { - if (post.data.tags) { - for (const tag of post.data.tags) { - tagSet.add(tag); - } - } - } - - return Array.from(tagSet).toSorted(); -}; - -export const getPostsByTag = (tag: string) => { - return posts - .filter((post) => post.data.tags?.includes(tag)) - .toSorted((a, b) => b.data.date.getTime() - a.data.date.getTime()); -}; - -export type Page = InferPageType<typeof source>; -export type Meta = InferMetaType<typeof source>; diff --git a/src/migrations/20251215_093857.json b/src/migrations/20251215_093857.json new file mode 100644 index 0000000..fad24c8 --- /dev/null +++ b/src/migrations/20251215_093857.json @@ -0,0 +1,1264 @@ +{ + "id": "af74e03d-43f6-47cf-8c1d-570aacc337d5", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "payload.users_sessions": { + "name": "users_sessions", + "schema": "payload", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "users_sessions_order_idx": { + "name": "users_sessions_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_sessions_parent_id_idx": { + "name": "users_sessions_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_sessions_parent_id_fk": { + "name": "users_sessions_parent_id_fk", + "tableFrom": "users_sessions", + "tableTo": "users", + "schemaTo": "payload", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.users": { + "name": "users", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "reset_password_token": { + "name": "reset_password_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "reset_password_expiration": { + "name": "reset_password_expiration", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "salt": { + "name": "salt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "login_attempts": { + "name": "login_attempts", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "lock_until": { + "name": "lock_until", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_updated_at_idx": { + "name": "users_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.posts_tags": { + "name": "posts_tags", + "schema": "payload", + "columns": { + "_order": { + "name": "_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "_parent_id": { + "name": "_parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "varchar", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "posts_tags_order_idx": { + "name": "posts_tags_order_idx", + "columns": [ + { + "expression": "_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_tags_parent_id_idx": { + "name": "posts_tags_parent_id_idx", + "columns": [ + { + "expression": "_parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_tags_parent_id_fk": { + "name": "posts_tags_parent_id_fk", + "tableFrom": "posts_tags", + "tableTo": "posts", + "schemaTo": "payload", + "columnsFrom": [ + "_parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.posts": { + "name": "posts", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "featured_image_id": { + "name": "featured_image_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'Admin'" + }, + "status": { + "name": "status", + "type": "enum_posts_status", + "typeSchema": "payload", + "primaryKey": false, + "notNull": false, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "posts_slug_idx": { + "name": "posts_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_featured_image_idx": { + "name": "posts_featured_image_idx", + "columns": [ + { + "expression": "featured_image_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_updated_at_idx": { + "name": "posts_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "posts_created_at_idx": { + "name": "posts_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "posts_featured_image_id_media_id_fk": { + "name": "posts_featured_image_id_media_id_fk", + "tableFrom": "posts", + "tableTo": "media", + "schemaTo": "payload", + "columnsFrom": [ + "featured_image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.media": { + "name": "media", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alt": { + "name": "alt", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "thumbnail_u_r_l": { + "name": "thumbnail_u_r_l", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "filesize": { + "name": "filesize", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_x": { + "name": "focal_x", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "focal_y": { + "name": "focal_y", + "type": "numeric", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "media_updated_at_idx": { + "name": "media_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_created_at_idx": { + "name": "media_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_filename_idx": { + "name": "media_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_kv": { + "name": "payload_kv", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payload_kv_key_idx": { + "name": "payload_kv_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_locked_documents": { + "name": "payload_locked_documents", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "global_slug": { + "name": "global_slug", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_locked_documents_global_slug_idx": { + "name": "payload_locked_documents_global_slug_idx", + "columns": [ + { + "expression": "global_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_updated_at_idx": { + "name": "payload_locked_documents_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_created_at_idx": { + "name": "payload_locked_documents_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_locked_documents_rels": { + "name": "payload_locked_documents_rels", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "posts_id": { + "name": "posts_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_locked_documents_rels_order_idx": { + "name": "payload_locked_documents_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_parent_idx": { + "name": "payload_locked_documents_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_path_idx": { + "name": "payload_locked_documents_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_users_id_idx": { + "name": "payload_locked_documents_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_posts_id_idx": { + "name": "payload_locked_documents_rels_posts_id_idx", + "columns": [ + { + "expression": "posts_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_locked_documents_rels_media_id_idx": { + "name": "payload_locked_documents_rels_media_id_idx", + "columns": [ + { + "expression": "media_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_locked_documents_rels_parent_fk": { + "name": "payload_locked_documents_rels_parent_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "payload_locked_documents", + "schemaTo": "payload", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_users_fk": { + "name": "payload_locked_documents_rels_users_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "users", + "schemaTo": "payload", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_posts_fk": { + "name": "payload_locked_documents_rels_posts_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "posts", + "schemaTo": "payload", + "columnsFrom": [ + "posts_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_locked_documents_rels_media_fk": { + "name": "payload_locked_documents_rels_media_fk", + "tableFrom": "payload_locked_documents_rels", + "tableTo": "media", + "schemaTo": "payload", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_preferences": { + "name": "payload_preferences", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_preferences_key_idx": { + "name": "payload_preferences_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_updated_at_idx": { + "name": "payload_preferences_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_created_at_idx": { + "name": "payload_preferences_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_preferences_rels": { + "name": "payload_preferences_rels", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "users_id": { + "name": "users_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payload_preferences_rels_order_idx": { + "name": "payload_preferences_rels_order_idx", + "columns": [ + { + "expression": "order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_parent_idx": { + "name": "payload_preferences_rels_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_path_idx": { + "name": "payload_preferences_rels_path_idx", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_preferences_rels_users_id_idx": { + "name": "payload_preferences_rels_users_id_idx", + "columns": [ + { + "expression": "users_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payload_preferences_rels_parent_fk": { + "name": "payload_preferences_rels_parent_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "payload_preferences", + "schemaTo": "payload", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payload_preferences_rels_users_fk": { + "name": "payload_preferences_rels_users_fk", + "tableFrom": "payload_preferences_rels", + "tableTo": "users", + "schemaTo": "payload", + "columnsFrom": [ + "users_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "payload.payload_migrations": { + "name": "payload_migrations", + "schema": "payload", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "batch": { + "name": "batch", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payload_migrations_updated_at_idx": { + "name": "payload_migrations_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payload_migrations_created_at_idx": { + "name": "payload_migrations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "payload.enum_posts_status": { + "name": "enum_posts_status", + "schema": "payload", + "values": [ + "draft", + "published" + ] + } + }, + "schemas": { + "payload": "payload" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +}
\ No newline at end of file diff --git a/src/migrations/20251215_093857.ts b/src/migrations/20251215_093857.ts new file mode 100644 index 0000000..1a53568 --- /dev/null +++ b/src/migrations/20251215_093857.ts @@ -0,0 +1,171 @@ +import type { MigrateUpArgs, MigrateDownArgs } from '@payloadcms/db-postgres' +import { sql } from 'drizzle-orm' + +export async function up({ db }: MigrateUpArgs): Promise<void> { + await db.execute(sql` + CREATE TYPE "payload"."enum_posts_status" AS ENUM('draft', 'published'); + CREATE TABLE "payload"."users_sessions" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "created_at" timestamp(3) with time zone, + "expires_at" timestamp(3) with time zone NOT NULL + ); + + CREATE TABLE "payload"."users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "email" varchar NOT NULL, + "reset_password_token" varchar, + "reset_password_expiration" timestamp(3) with time zone, + "salt" varchar, + "hash" varchar, + "login_attempts" numeric DEFAULT 0, + "lock_until" timestamp(3) with time zone + ); + + CREATE TABLE "payload"."posts_tags" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "tag" varchar + ); + + CREATE TABLE "payload"."posts" ( + "id" serial PRIMARY KEY NOT NULL, + "title" varchar NOT NULL, + "slug" varchar NOT NULL, + "description" varchar, + "content" jsonb NOT NULL, + "featured_image_id" integer, + "author" varchar DEFAULT 'Admin', + "status" "payload"."enum_posts_status" DEFAULT 'draft', + "published_at" timestamp(3) with time zone, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE "payload"."media" ( + "id" serial PRIMARY KEY NOT NULL, + "alt" varchar, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "url" varchar, + "thumbnail_u_r_l" varchar, + "filename" varchar, + "mime_type" varchar, + "filesize" numeric, + "width" numeric, + "height" numeric, + "focal_x" numeric, + "focal_y" numeric + ); + + CREATE TABLE "payload"."payload_kv" ( + "id" serial PRIMARY KEY NOT NULL, + "key" varchar NOT NULL, + "data" jsonb NOT NULL + ); + + CREATE TABLE "payload"."payload_locked_documents" ( + "id" serial PRIMARY KEY NOT NULL, + "global_slug" varchar, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE "payload"."payload_locked_documents_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "users_id" integer, + "posts_id" integer, + "media_id" integer + ); + + CREATE TABLE "payload"."payload_preferences" ( + "id" serial PRIMARY KEY NOT NULL, + "key" varchar, + "value" jsonb, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE "payload"."payload_preferences_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "users_id" integer + ); + + CREATE TABLE "payload"."payload_migrations" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar, + "batch" numeric, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + ALTER TABLE "payload"."users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."posts_tags" ADD CONSTRAINT "posts_tags_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "payload"."media"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action; + ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action; + CREATE INDEX "users_sessions_order_idx" ON "payload"."users_sessions" USING btree ("_order"); + CREATE INDEX "users_sessions_parent_id_idx" ON "payload"."users_sessions" USING btree ("_parent_id"); + CREATE INDEX "users_updated_at_idx" ON "payload"."users" USING btree ("updated_at"); + CREATE INDEX "users_created_at_idx" ON "payload"."users" USING btree ("created_at"); + CREATE UNIQUE INDEX "users_email_idx" ON "payload"."users" USING btree ("email"); + CREATE INDEX "posts_tags_order_idx" ON "payload"."posts_tags" USING btree ("_order"); + CREATE INDEX "posts_tags_parent_id_idx" ON "payload"."posts_tags" USING btree ("_parent_id"); + CREATE UNIQUE INDEX "posts_slug_idx" ON "payload"."posts" USING btree ("slug"); + CREATE INDEX "posts_featured_image_idx" ON "payload"."posts" USING btree ("featured_image_id"); + CREATE INDEX "posts_updated_at_idx" ON "payload"."posts" USING btree ("updated_at"); + CREATE INDEX "posts_created_at_idx" ON "payload"."posts" USING btree ("created_at"); + CREATE INDEX "media_updated_at_idx" ON "payload"."media" USING btree ("updated_at"); + CREATE INDEX "media_created_at_idx" ON "payload"."media" USING btree ("created_at"); + CREATE UNIQUE INDEX "media_filename_idx" ON "payload"."media" USING btree ("filename"); + CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload"."payload_kv" USING btree ("key"); + CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload"."payload_locked_documents" USING btree ("global_slug"); + CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload"."payload_locked_documents" USING btree ("updated_at"); + CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload"."payload_locked_documents" USING btree ("created_at"); + CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload"."payload_locked_documents_rels" USING btree ("order"); + CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload"."payload_locked_documents_rels" USING btree ("parent_id"); + CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload"."payload_locked_documents_rels" USING btree ("path"); + CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("users_id"); + CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("posts_id"); + CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("media_id"); + CREATE INDEX "payload_preferences_key_idx" ON "payload"."payload_preferences" USING btree ("key"); + CREATE INDEX "payload_preferences_updated_at_idx" ON "payload"."payload_preferences" USING btree ("updated_at"); + CREATE INDEX "payload_preferences_created_at_idx" ON "payload"."payload_preferences" USING btree ("created_at"); + CREATE INDEX "payload_preferences_rels_order_idx" ON "payload"."payload_preferences_rels" USING btree ("order"); + CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload"."payload_preferences_rels" USING btree ("parent_id"); + CREATE INDEX "payload_preferences_rels_path_idx" ON "payload"."payload_preferences_rels" USING btree ("path"); + CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload"."payload_preferences_rels" USING btree ("users_id"); + CREATE INDEX "payload_migrations_updated_at_idx" ON "payload"."payload_migrations" USING btree ("updated_at"); + CREATE INDEX "payload_migrations_created_at_idx" ON "payload"."payload_migrations" USING btree ("created_at");`) +} + +export async function down({ db }: MigrateDownArgs): Promise<void> { + await db.execute(sql` + DROP TABLE "payload"."users_sessions" CASCADE; + DROP TABLE "payload"."users" CASCADE; + DROP TABLE "payload"."posts_tags" CASCADE; + DROP TABLE "payload"."posts" CASCADE; + DROP TABLE "payload"."media" CASCADE; + DROP TABLE "payload"."payload_kv" CASCADE; + DROP TABLE "payload"."payload_locked_documents" CASCADE; + DROP TABLE "payload"."payload_locked_documents_rels" CASCADE; + DROP TABLE "payload"."payload_preferences" CASCADE; + DROP TABLE "payload"."payload_preferences_rels" CASCADE; + DROP TABLE "payload"."payload_migrations" CASCADE; + DROP TYPE "payload"."enum_posts_status";`) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts new file mode 100644 index 0000000..2a97f8d --- /dev/null +++ b/src/migrations/index.ts @@ -0,0 +1,9 @@ +import * as migration_20251215_093857 from './20251215_093857'; + +export const migrations = [ + { + up: migration_20251215_093857.up, + down: migration_20251215_093857.down, + name: '20251215_093857' + }, +]; diff --git a/src/payload/collections/Media.ts b/src/payload/collections/Media.ts new file mode 100644 index 0000000..45525db --- /dev/null +++ b/src/payload/collections/Media.ts @@ -0,0 +1,23 @@ +import type { CollectionConfig } from 'payload'; + +export const Media: CollectionConfig = { + slug: 'media', + labels: { + singular: 'media', + plural: 'medias', + }, + access: { + read: () => true, + }, + upload: { + staticDir: 'public/media', + mimeTypes: ['image/*'], + }, + fields: [ + { + name: 'alt', + label: '替代文本', + type: 'text', + }, + ], +}; diff --git a/src/payload/collections/Posts.ts b/src/payload/collections/Posts.ts new file mode 100644 index 0000000..6ecf898 --- /dev/null +++ b/src/payload/collections/Posts.ts @@ -0,0 +1,93 @@ +import type { CollectionConfig } from 'payload'; + +export const Posts: CollectionConfig = { + slug: 'posts', + labels: { + singular: 'posts', + plural: 'posts', + }, + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'status', 'publishedAt', 'updatedAt'], + }, + access: { + read: () => true, + }, + fields: [ + { + name: 'title', + label: 'title', + type: 'text', + required: true, + }, + { + name: 'slug', + label: 'slug', + type: 'text', + required: true, + unique: true, + admin: { + description: 'example:my-first-post', + }, + }, + { + name: 'description', + label: 'description', + type: 'textarea', + }, + { + name: 'content', + label: 'content', + type: 'richText', + required: true, + }, + { + name: 'featuredImage', + label: 'image', + type: 'upload', + relationTo: 'media', + }, + { + name: 'author', + label: 'author', + type: 'text', + defaultValue: 'Admin', + }, + { + name: 'tags', + label: 'tag', + type: 'array', + fields: [ + { + name: 'tag', + label: 'tag', + type: 'text', + }, + ], + }, + { + name: 'status', + label: 'status', + type: 'select', + defaultValue: 'draft', + options: [ + { label: 'draft', value: 'draft' }, + { label: 'published', value: 'published' }, + ], + admin: { + position: 'sidebar', + }, + }, + { + name: 'publishedAt', + label: 'publishedAt', + type: 'date', + admin: { + position: 'sidebar', + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + ], +}; diff --git a/src/payload/collections/Users.ts b/src/payload/collections/Users.ts new file mode 100644 index 0000000..730cde2 --- /dev/null +++ b/src/payload/collections/Users.ts @@ -0,0 +1,20 @@ +import type { CollectionConfig } from 'payload'; + +export const Users: CollectionConfig = { + slug: 'users', + labels: { + singular: 'users', + plural: 'users', + }, + admin: { + useAsTitle: 'email', + }, + auth: true, + fields: [ + { + name: 'name', + label: 'name', + type: 'text', + }, + ], +}; diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 8579424..6a8821e 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -1,13 +1,15 @@ -import { neon } from '@neondatabase/serverless'; -import { drizzle } from 'drizzle-orm/neon-http'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; import { env } from '@/env'; import * as schema from './schema'; -const sql = neon(env.DATABASE_URL); +const pool = new Pool({ + connectionString: env.DATABASE_URL, +}); export const db = drizzle({ - client: sql, + client: pool, schema, casing: 'snake_case', }); |
