feat: post browser infinite scroll

This commit is contained in:
2025-01-06 22:31:26 +01:00
parent 711ebf8bb1
commit 04b8a91e80
5 changed files with 158 additions and 23 deletions

View File

@@ -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',
},
});
}

View File

@@ -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() {
</p>
{Boolean(process.env.SAFEBOORU_PULL) && (
<p className="text-red-300">
Development instance is pulling the first 30 safebooru images it finds for demonstration
purposes.
This development instance pulls some safebooru images for testing.
</p>
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 p-4">
{posts.map((post) => (
<div key={post.id} className="relative aspect-square">
<Image
width={176}
height={176}
src={post.imageUrl}
alt={''}
className="w-full h-full object-contain border-2 rounded-md border-dashed pointer-events-none"
blurDataURL={`data:image/jpeg;base64,${post.previewHash}`}
placeholder="blur"
/>
<Link
href={`/post/${post.id}`}
className="absolute inset-0 z-10 hover:bg-muted-foreground/5"
aria-label="View post details"
/>
</div>
))}
</div>
<PostBrowser />
</div>
);
}

View File

@@ -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<Post[]>([]);
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 (
<InfiniteScroll isLoading={loading} hasMore={hasMore} next={next} threshold={0}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 p-4">
{posts.map((post) => (
<div key={post.id} className="relative aspect-square">
<Image
width={176}
height={176}
src={post.imageUrl}
alt=""
className="w-full h-full object-contain border-2 rounded-md border-dashed pointer-events-none"
blurDataURL={`data:image/jpeg;base64,${post.previewHash}`}
placeholder="blur"
/>
<Link
href={`/post/${post.id}`}
className="absolute inset-0 z-10 hover:bg-muted-foreground/5"
aria-label="View post details"
/>
</div>
))}
{loading &&
Array.from({ length: page === 0 ? 12 : 2 }).map((_, i) => (
<div key={i} className="relative aspect-square">
<Skeleton className="w-full h-full rounded-md border-2 border-dashed" />
</div>
))}
</div>
{/* interactionobserver sentinel */}
<div style={{ height: 1 }} />
</InfiniteScroll>
);
}

View File

@@ -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<IntersectionObserver>();
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 });
})}
</>
);
}

View File

@@ -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();