summaryrefslogtreecommitdiff
path: root/src/app/(main)/(home)/posts
diff options
context:
space:
mode:
authorBertrand Yuan <bert.yuan@outlook.com>2025-12-16 00:25:04 +0800
committerGitHub <noreply@github.com>2025-12-16 00:25:04 +0800
commit39c83fbb69ef06d2d56790d75abc254ba7e34394 (patch)
treedd006593448c3500bdcb414af3b4656f7a7683d4 /src/app/(main)/(home)/posts
parent48b07bc308a35734a6a7a305c8fdccbfa47de7d8 (diff)
parent785371bb3eccca455e5ce5fccbe9b6e3752a03f6 (diff)
Merge pull request #1 from bertyuan/feat-introduce-payloadv1.0
Feat: introduce payload
Diffstat (limited to 'src/app/(main)/(home)/posts')
-rw-r--r--src/app/(main)/(home)/posts/[slug]/page.client.tsx57
-rw-r--r--src/app/(main)/(home)/posts/[slug]/page.tsx129
-rw-r--r--src/app/(main)/(home)/posts/page.tsx143
3 files changed, 329 insertions, 0 deletions
diff --git a/src/app/(main)/(home)/posts/[slug]/page.client.tsx b/src/app/(main)/(home)/posts/[slug]/page.client.tsx
new file mode 100644
index 0000000..7a97f56
--- /dev/null
+++ b/src/app/(main)/(home)/posts/[slug]/page.client.tsx
@@ -0,0 +1,57 @@
+'use client';
+import {
+ UploadIcon as ShareIcon,
+ type UploadIconHandle as ShareIconHandle,
+} from '@/components/icons/animated/upload';
+import { Icons } from '@/components/icons/icons';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Comments } from '@fuma-comment/react';
+import { redirect } from 'next/navigation';
+import { useRef } from 'react';
+import { toast } from 'sonner';
+import { useCopyToClipboard } from 'usehooks-ts';
+
+export function Share({ url }: { url: string }): React.ReactElement {
+ const iconRef = useRef<ShareIconHandle>(null);
+ const [_, copyToClipboard] = useCopyToClipboard();
+
+ const onClick = async (): Promise<void> => {
+ await copyToClipboard(`${window.location.origin}${url}`);
+ toast.success('Copied to clipboard!', {
+ icon: <Icons.copied className='size-4' />,
+ description: 'The post link has been copied to your clipboard.',
+ });
+ };
+
+ return (
+ <Button
+ className={cn('group gap-2')}
+ variant={'secondary'}
+ onClick={onClick}
+ onMouseEnter={() => iconRef.current?.startAnimation?.()}
+ onMouseLeave={() => iconRef.current?.stopAnimation?.()}
+ >
+ <ShareIcon className='size-4 hover:bg-transparent' ref={iconRef} />
+ Share Post
+ </Button>
+ );
+}
+
+export function PostComments({
+ slug,
+ className,
+}: { slug: string; className?: string }) {
+ return (
+ <Comments
+ page={slug}
+ className={cn('w-full', className)}
+ auth={{
+ type: 'api',
+ signIn: () => {
+ redirect('/login');
+ },
+ }}
+ />
+ );
+}
diff --git a/src/app/(main)/(home)/posts/[slug]/page.tsx b/src/app/(main)/(home)/posts/[slug]/page.tsx
new file mode 100644
index 0000000..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/(main)/(home)/posts/page.tsx b/src/app/(main)/(home)/posts/page.tsx
new file mode 100644
index 0000000..40ebcda
--- /dev/null
+++ b/src/app/(main)/(home)/posts/page.tsx
@@ -0,0 +1,143 @@
+import { postsPerPage } from '@/app/(main)/layout.config';
+import { NumberedPagination } from '@/components/numbered-pagination';
+import { PostCard } from '@/components/posts/post-card';
+import { Section } from '@/components/section';
+import { createMetadata } from '@/lib/metadata';
+import { getPublishedPosts } from '@/lib/payload-posts';
+import type { Metadata, ResolvingMetadata } from 'next';
+import { notFound, redirect } from 'next/navigation';
+
+const CurrentPostsCount = ({
+ startIndex,
+ endIndex,
+ totalPosts,
+}: {
+ startIndex: number;
+ endIndex: number;
+ totalPosts: number;
+}) => {
+ const start = startIndex + 1;
+ const end = endIndex < totalPosts ? endIndex : totalPosts;
+ if (start === end) return <span>({start})</span>;
+ return (
+ <span>
+ ({start}-{end})
+ </span>
+ );
+};
+
+const Pagination = ({
+ pageIndex,
+ pageCount,
+}: {
+ pageIndex: number;
+ pageCount: number;
+}) => {
+ const handlePageChange = async (page: number) => {
+ 'use server';
+ redirect(`/posts?page=${page}`);
+ };
+
+ return (
+ <Section className='bg-dashed'>
+ <NumberedPagination
+ currentPage={pageIndex + 1}
+ totalPages={pageCount}
+ paginationItemsToDisplay={5}
+ onPageChange={handlePageChange}
+ />
+ </Section>
+ );
+};
+
+export default async function Page(props: {
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+}) {
+ const searchParams = await props.searchParams;
+
+ const pageIndex = searchParams.page
+ ? Number.parseInt(
+ Array.isArray(searchParams.page)
+ ? searchParams.page[0] ?? ''
+ : searchParams.page,
+ 10
+ ) - 1
+ : 0;
+
+ // 获取文章(带分页)
+ 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 + posts.length;
+
+ return (
+ <>
+ <Section className='p-4 lg:p-6'>
+ <h1 className='font-bold text-3xl leading-tight tracking-tighter md:text-4xl'>
+ 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 = post.date.toDateString();
+ return (
+ <PostCard
+ title={post.title}
+ description={post.description}
+ image={post.image}
+ url={post.url}
+ date={date}
+ key={post.id}
+ author={post.author}
+ tags={post.tags}
+ />
+ );
+ })}
+ </div>
+ </Section>
+ {totalPages > 1 && <Pagination pageIndex={pageIndex} pageCount={totalPages} />}
+ </>
+ );
+}
+
+type Props = {
+ params: Promise<{ slug: string[] }>;
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
+};
+
+export async function generateMetadata(
+ props: Props,
+ parent: ResolvingMetadata
+): Promise<Metadata> {
+ const searchParams = await props.searchParams;
+
+ const pageIndex = searchParams.page
+ ? Number.parseInt(searchParams.page as string, 10)
+ : 1;
+
+ const isFirstPage = pageIndex === 1 || !searchParams.page;
+ const pageTitle = isFirstPage ? 'Posts' : `Posts - Page ${pageIndex}`;
+ const canonicalUrl = isFirstPage ? '/posts' : `/posts?page=${pageIndex}`;
+
+ return createMetadata({
+ title: pageTitle,
+ description: `Posts${!isFirstPage ? ` - Page ${pageIndex}` : ''}`,
+ openGraph: {
+ url: canonicalUrl,
+ },
+ alternates: {
+ canonical: canonicalUrl,
+ },
+ });
+}