mirror of
https://github.com/SrIzan10/nextbooru.git
synced 2026-07-05 06:39:43 +00:00
feat: post browser infinite scroll
This commit is contained in:
22
src/app/(public)/api/posts/route.ts
Normal file
22
src/app/(public)/api/posts/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/components/app/PostBrowser/PostBrowser.tsx
Normal file
64
src/components/app/PostBrowser/PostBrowser.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
68
src/components/ui/infinite-scroll.tsx
Normal file
68
src/components/ui/infinite-scroll.tsx
Normal 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 });
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user