From 04b8a91e8043ebbcdca59eb6d1a26c0ed0697b94 Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Mon, 6 Jan 2025 22:31:26 +0100 Subject: [PATCH] feat: post browser infinite scroll --- src/app/(public)/api/posts/route.ts | 22 ++++++ src/app/(public)/page.tsx | 25 +------ .../app/PostBrowser/PostBrowser.tsx | 64 +++++++++++++++++ src/components/ui/infinite-scroll.tsx | 68 +++++++++++++++++++ src/instrumentation.ts | 2 +- 5 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 src/app/(public)/api/posts/route.ts create mode 100644 src/components/app/PostBrowser/PostBrowser.tsx create mode 100644 src/components/ui/infinite-scroll.tsx diff --git a/src/app/(public)/api/posts/route.ts b/src/app/(public)/api/posts/route.ts new file mode 100644 index 0000000..7855818 --- /dev/null +++ b/src/app/(public)/api/posts/route.ts @@ -0,0 +1,22 @@ +import prisma from '@/lib/db'; +import { type NextRequest } from 'next/server'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const page = parseInt(searchParams.get('page')!); + if (page && isNaN(page)) { + return new Response('Invalid page number', { status: 400 }); + } + + const queryPosts = await prisma.post.findMany({ + take: 30, + skip: page * 30, + orderBy: { createdAt: 'desc' }, + }); + + return new Response(JSON.stringify(queryPosts), { + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 101a6da..f2a5dae 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,3 +1,4 @@ +import PostBrowser from '@/components/app/PostBrowser/PostBrowser'; import prisma from '@/lib/db'; import Image from 'next/image'; import Link from 'next/link'; @@ -13,30 +14,10 @@ export default async function Home() {

{Boolean(process.env.SAFEBOORU_PULL) && (

- Development instance is pulling the first 30 safebooru images it finds for demonstration - purposes. + This development instance pulls some safebooru images for testing.

)} -
- {posts.map((post) => ( -
- {''} - -
- ))} -
+ ); } diff --git a/src/components/app/PostBrowser/PostBrowser.tsx b/src/components/app/PostBrowser/PostBrowser.tsx new file mode 100644 index 0000000..f6105dd --- /dev/null +++ b/src/components/app/PostBrowser/PostBrowser.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React from 'react'; +import InfiniteScroll from '@/components/ui/infinite-scroll'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Post } from '@prisma/client'; +import Image from 'next/image'; +import Link from 'next/link'; + +export default function PostBrowser() { + const [page, setPage] = React.useState(0); + const [loading, setLoading] = React.useState(false); + const [hasMore, setHasMore] = React.useState(true); + const [posts, setPosts] = React.useState([]); + + const next = async () => { + setLoading(true); + const res = await fetch(`/api/posts?page=${page}`); + const newImages = await res.json(); + if (!newImages.length) { + setHasMore(false); + setLoading(false); + return; + } + setPosts((prev) => [...prev, ...newImages]); + setPage((prev) => prev + 1); + setLoading(false); + }; + + return ( + +
+ {posts.map((post) => ( +
+ + +
+ ))} + {loading && + Array.from({ length: page === 0 ? 12 : 2 }).map((_, i) => ( +
+ +
+ ))} +
+ + {/* interactionobserver sentinel */} +
+ + ); +} + diff --git a/src/components/ui/infinite-scroll.tsx b/src/components/ui/infinite-scroll.tsx new file mode 100644 index 0000000..6e5c7bf --- /dev/null +++ b/src/components/ui/infinite-scroll.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +interface InfiniteScrollProps { + isLoading: boolean; + hasMore: boolean; + next: () => unknown; + threshold?: number; + root?: Element | Document | null; + rootMargin?: string; + reverse?: boolean; + children?: React.ReactNode; +} + +export default function InfiniteScroll({ + isLoading, + hasMore, + next, + threshold = 1, + root = null, + rootMargin = '0px', + reverse, + children, +}: InfiniteScrollProps) { + const observer = React.useRef(); + + const observerRef = React.useCallback( + (element: HTMLElement | null) => { + let safeThreshold = threshold; + if (threshold < 0 || threshold > 1) { + console.warn('threshold should be between 0 and 1. Using default: 1'); + safeThreshold = 1; + } + + if (observer.current) observer.current.disconnect(); + if (!element) return; + + observer.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + next(); + } + }, + { threshold: safeThreshold, root, rootMargin } + ); + observer.current.observe(element); + }, + [hasMore, isLoading, next, threshold, root, rootMargin] + ); + + const flattenChildren = React.useMemo(() => React.Children.toArray(children), [children]); + + return ( + <> + {flattenChildren.map((child, index) => { + if (!React.isValidElement(child)) { + process.env.NODE_ENV === 'development' && + console.warn('You should use a valid element with InfiniteScroll'); + return child; + } + + const isObserveTarget = reverse ? index === 0 : index === flattenChildren.length - 1; + const ref = isObserveTarget ? observerRef : null; + // @ts-ignore + return React.cloneElement(child, { ref }); + })} + + ); +} \ No newline at end of file diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 7528a60..39114ef 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -28,7 +28,7 @@ export async function register() { console.log('Pulling safebooru images...'); console.log('Fetching...'); const res = await fetch( - 'https://safebooru.org/index.php?page=dapi&s=post&q=index&json=1&limit=30', + 'https://safebooru.org/index.php?page=dapi&s=post&q=index&json=1&limit=40', { headers: { 'User-Agent': 'nextbooru' } } ); const posts = await res.json();