diff --git a/bun.lockb b/bun.lockb index f5b37f3..92280e0 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 65e49f8..18840f7 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", + "@tanstack/react-query": "^5.53.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "framer-motion": "^11.3.16", @@ -43,6 +44,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.52.1", + "react-intersection-observer": "^9.13.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.23.8" diff --git a/src/app/(default)/community/_components/infinite-scroll-art.tsx b/src/app/(default)/community/_components/infinite-scroll-art.tsx new file mode 100644 index 0000000..af3dc85 --- /dev/null +++ b/src/app/(default)/community/_components/infinite-scroll-art.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { ArtCards, SkeletonCards } from '@/components/art-cards'; +import type { ArtsResponse, Resolution } from '@/types'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { useInView } from 'react-intersection-observer'; + +export const InfiniteScrollArt = ({ + resolution, +}: { resolution: Resolution }) => { + const { ref, inView } = useInView(); + + const fetchArts = async ({ + pageParam, + }: { pageParam: number }): Promise => { + const seartchParams = new URLSearchParams(); + seartchParams.set('cursor', pageParam.toString()); + seartchParams.set('width', resolution === 'fullhd' ? '26' : '27'); + + const res = await fetch(`/api/arts/?${seartchParams}`); + return res.json(); + }; + + const { + data, + error, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + status, + } = useInfiniteQuery({ + queryKey: ['arts'], + queryFn: fetchArts, + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.next, + }); + + useEffect(() => { + if (inView) { + fetchNextPage(); + } + }, [fetchNextPage, inView]); + + return status === 'pending' ? ( + + ) : status === 'error' ? ( +

Error: {error.message}

+ ) : ( + <> + {data.pages.map((page, i) => ( + + ))} +
+ +
+
+ {isFetching && !isFetchingNextPage ? 'Fetching...' : null} +
+ + ); +}; diff --git a/src/app/(default)/community/layout.tsx b/src/app/(default)/community/layout.tsx new file mode 100644 index 0000000..4de0221 --- /dev/null +++ b/src/app/(default)/community/layout.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; + +const queryClient = new QueryClient(); + +const Layout = ({ children }: { children: ReactNode }) => { + return ( + {children} + ); +}; + +export default Layout; diff --git a/src/app/(default)/community/page.tsx b/src/app/(default)/community/page.tsx index a303556..7a89383 100644 --- a/src/app/(default)/community/page.tsx +++ b/src/app/(default)/community/page.tsx @@ -1,31 +1,10 @@ -import { prisma } from '@/client'; -import { ArtCards, SkeletonCards } from '@/components/art-cards'; import type { Metadata } from 'next'; -import { Suspense } from 'react'; - -export const dynamic = 'force-dynamic'; - -const ArtsList = async () => { - const arts = await prisma.art.findMany({ - orderBy: { - createdAt: 'desc', - }, - }); - return ; -}; +import { InfiniteScrollArt } from './_components/infinite-scroll-art'; const Page = () => { return ( -
-
- )} - > - - -
+
+
); }; diff --git a/src/app/api/arts/route.ts b/src/app/api/arts/route.ts new file mode 100644 index 0000000..18bdf7e --- /dev/null +++ b/src/app/api/arts/route.ts @@ -0,0 +1,47 @@ +import { prisma } from '@/client'; +import { type NextRequest, NextResponse } from 'next/server'; + +export const GET = async (req: NextRequest) => { + const pageSize = 20; + + const searchParams = req.nextUrl.searchParams; + const cursor = Number.parseInt(searchParams.get('cursor') ?? '0'); + const width = Number.parseInt(searchParams.get('width') ?? '26'); + + if (width !== 26 && width !== 27) { + return NextResponse.json({ success: false }, { status: 400 }); + } + + if (Number.isNaN(cursor)) { + return NextResponse.json({ success: false }, { status: 400 }); + } + + try { + const artsCount = await prisma.art.count({ + where: { + width, + }, + }); + + if (cursor > artsCount) { + return NextResponse.json({ success: false }, { status: 404 }); + } + + const arts = await prisma.art.findMany({ + orderBy: { + createdAt: 'desc', + }, + where: { + width, + }, + skip: cursor, + take: pageSize, + }); + + const next = cursor + pageSize < artsCount ? cursor + pageSize : null; + + return NextResponse.json({ success: true, data: arts, next }); + } catch { + return NextResponse.json({ success: false }, { status: 500 }); + } +}; diff --git a/src/components/art-cards.tsx b/src/components/art-cards.tsx index 049b8d7..32d42f0 100644 --- a/src/components/art-cards.tsx +++ b/src/components/art-cards.tsx @@ -15,7 +15,7 @@ import { Skeleton } from './ui/skeleton'; const ArtCard = ({ art }: { art: Art }) => { const asciiData = unflattenArray(art.body); - const date = formatDate(art.createdAt); + const date = formatDate(new Date(art.createdAt)); return ( { }; export const SkeletonCards = () => { - return ; + return ( +
+ {Array(3) + .fill(0) + .map((_, i) => ( + + ))} +
+ ); }; diff --git a/src/types.ts b/src/types.ts index 74d5f3f..3bfca5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { Art } from '@prisma/client'; import type { z } from 'zod'; import type { heightSchema } from './schemas'; @@ -25,3 +26,9 @@ export type ShareArtResponse = { slug?: string; error?: unknown; }; + +export type ArtsResponse = { + succsess: boolean; + data?: Array; + next?: number; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 2e25a46..c39485e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -40,7 +40,14 @@ export const formatDate = (date: Date): string => { const locale = getUserLocale(); const timeZone = getUserTimeZone(); - return date.toLocaleDateString(locale, { + return date.toLocaleString(locale, { timeZone: timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: true, }); };