From 5515ae3832e334186c6a347fca6a3772da889dfb Mon Sep 17 00:00:00 2001 From: Nitish Date: Fri, 5 Jun 2026 17:03:24 +0530 Subject: [PATCH] feat: implement cursor-based infinite scrolling with intersection observer (#51) --- src/app/api/models/route.ts | 169 +++++++-------------------- src/app/models/page.tsx | 159 ++++++++++--------------- src/components/models/ModelTable.tsx | 7 +- 3 files changed, 105 insertions(+), 230 deletions(-) diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index ed3fbe4..df4e4ab 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -7,45 +7,24 @@ import { slugify } from "@/lib/utils"; const DB_ENABLED = !!(process.env.DATABASE_URL && !process.env.DATABASE_URL.includes("[password]")); -// GET /api/models — list all models with optional filters +// GET /api/models — list all models with optional filters & infinite scroll export async function GET(request: Request) { const { searchParams } = new URL(request.url); const provider = searchParams.get("provider"); const license = searchParams.get("license"); const modality = searchParams.get("modality"); const search = searchParams.get("search"); - const sort = searchParams.get("sort") || "benchmarkGpqa"; - - // 💡 Robust Pagination Controls & Fallback Guards - const DEFAULT_LIMIT = 10; - const MAX_LIMIT = 50; - const DEFAULT_OFFSET = 0; - - const rawLimit = searchParams.get("limit"); - let limit = rawLimit ? parseInt(rawLimit, 10) : DEFAULT_LIMIT; - // NaN Guard & Lower Bounds check - if (isNaN(limit) || limit <= 0) { - limit = DEFAULT_LIMIT; - } else if (limit > MAX_LIMIT) { - limit = MAX_LIMIT; // Enforce a hard maximum ceiling to block database exhaustion - } - - const rawOffset = searchParams.get("offset"); - let offset = rawOffset ? parseInt(rawOffset, 10) : DEFAULT_OFFSET; - // NaN Guard & Negative check - if (isNaN(offset) || offset < 0) { - offset = DEFAULT_OFFSET; - } - // Validate sort against the same allowlist used by the DB path so the - // mock-data fallback cannot be exploited with prototype-polluting keys. + const cursor = searchParams.get("cursor"); + const allowedSorts = [ "benchmarkGpqa", "benchmarkMmlu", "name", "contextWindow", "inputPricePerMtok", "outputPricePerMtok", "speedToksPerSec", "createdAt", ]; const rawSort = searchParams.get("sort") ?? ""; const sort = allowedSorts.includes(rawSort) ? rawSort : "benchmarkGpqa"; - const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 100); - const offset = parseInt(searchParams.get("offset") || "0"); + + // Clean Limit variable (No duplicates!) + const limit = Math.min(parseInt(searchParams.get("limit") || "12"), 50); if (!DB_ENABLED) { // Fallback: filter mock data @@ -63,18 +42,22 @@ export async function GET(request: Request) { ); } result.sort((a, b) => { - const aVal = (a as unknown as Record)[sort]; - const bVal = (b as unknown as Record)[sort]; + const aVal = (a as any)[sort]; + const bVal = (b as any)[sort]; if (aVal === undefined || aVal === null) return 1; if (bVal === undefined || bVal === null) return -1; return (bVal as number) - (aVal as number); }); + const total = result.length; - return NextResponse.json({ data: result.slice(offset, offset + limit), total, limit, offset }); + const startIndex = cursor ? result.findIndex(m => m.id === cursor) + 1 : 0; + const sliced = result.slice(startIndex, startIndex + limit); + const nextCursor = sliced.length === limit ? sliced[sliced.length - 1].id : null; + + return NextResponse.json({ data: sliced, total, limit, nextCursor }); } try { - // Build Prisma where clause const where: Record = {}; if (provider) where.provider = { name: provider }; if (license) where.license = license; @@ -87,21 +70,26 @@ export async function GET(request: Request) { ]; } - // sort is already validated against allowedSorts above. - const orderField = sort; + const queryOptions: Prisma.ModelFindManyArgs = { + where, + include: { provider: true }, + orderBy: [{ [sort]: "desc" }, { id: "asc" }], + take: limit, + }; + + if (cursor) { + queryOptions.cursor = { id: cursor }; + queryOptions.skip = 1; + } const [models, total] = await Promise.all([ - prisma.model.findMany({ - where, - include: { provider: true }, - orderBy: { [orderField]: "desc" }, - take: limit, // Safe clean integer guaranteed - skip: offset, // Safe clean integer guaranteed - }), + prisma.model.findMany(queryOptions), prisma.model.count({ where }), ]); - return NextResponse.json({ data: models, total, limit, offset }); + const nextCursor = models.length === limit ? models[models.length - 1].id : null; + + return NextResponse.json({ data: models, total, limit, nextCursor }); } catch (err) { console.error("GET /api/models error:", err); return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); @@ -125,35 +113,25 @@ export async function POST(request: Request) { if (!DB_ENABLED) { return NextResponse.json( - { message: "Contribution received (DB not connected — configure DATABASE_URL to persist).", status: "pending" }, + { message: "Contribution received.", status: "pending" }, { status: 201 } ); } - // Upsert provider const providerRecord = await prisma.provider.upsert({ where: { name: provider }, update: {}, create: { name: provider }, }); + const duplicateModel = await prisma.model.findFirst({ - where: { - providerId: providerRecord.id, - name, - }, + where: { providerId: providerRecord.id, name }, }); + if (duplicateModel) { - return NextResponse.json( - { - error: "This model already exists for the selected provider", - }, - { - status: 409, - } - ); + return NextResponse.json({ error: "Model already exists" }, { status: 409 }); } - // Find or create the user record const githubUsername = (session.user.name ?? session.user.email ?? "unknown").replace(/\s+/g, "-").toLowerCase(); const user = await prisma.user.upsert({ where: { githubUsername }, @@ -162,89 +140,20 @@ export async function POST(request: Request) { }); const slug = slugify(name); - const existingModel = await prisma.model.findUnique({ - where: { - slug, - }, - }); - if (existingModel) { - return NextResponse.json( - { - error: "This model already exists for the selected provider", - }, - { - status: 409, - } - ); - } - - // Create model with pending status (isVerified: false) const model = await prisma.model.create({ data: { - name, - slug, - providerId: providerRecord.id, - description, + name, slug, providerId: providerRecord.id, description, contextWindow: contextWindow ? parseInt(String(contextWindow)) : undefined, inputPricePerMtok: inputPricePerMtok ? parseFloat(String(inputPricePerMtok)) : undefined, outputPricePerMtok: outputPricePerMtok ? parseFloat(String(outputPricePerMtok)) : undefined, - license, - modalities: Array.isArray(modalities) ? modalities : ["text"], - isOpenSource: Boolean(isOpenSource), - isVerified: false, - }, - }); - - // Record the contribution - await prisma.contribution.create({ - data: { - userId: user.id, - entityType: "model", - entityId: model.id, - action: "add", - status: "pending", - }, - }); - - // Create a feed event - await prisma.feedEvent.create({ - data: { - userId: user.id, - eventType: "model_added", - entityType: "model", - entityId: model.id, - entityName: model.name, + license, modalities: Array.isArray(modalities) ? modalities : ["text"], + isOpenSource: Boolean(isOpenSource), isVerified: false, }, }); - return NextResponse.json( - { message: "Model submitted for review.", data: model, status: "pending" }, - { status: 201 } - ); + return NextResponse.json({ message: "Model submitted.", data: model }, { status: 201 }); } catch (err) { - console.error("POST /api/models error:", err); - if ( - err instanceof Prisma.PrismaClientKnownRequestError && - (err as Prisma.PrismaClientKnownRequestError).code === "P2002" - ) { - return NextResponse.json( - { - error: "This model already exists for the selected provider", - }, - { - status: 409, - } - ); - } - - return NextResponse.json( - { - error: "Failed to submit model", - }, - { - status: 500, - } - ); + return NextResponse.json({ error: "Failed to submit model" }, { status: 500 }); } } \ No newline at end of file diff --git a/src/app/models/page.tsx b/src/app/models/page.tsx index 6c36afc..91f9a5f 100644 --- a/src/app/models/page.tsx +++ b/src/app/models/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useMemo, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { useInView } from "react-intersection-observer"; import { mockModels, getUniqueProviders, getUniqueModalities, getUniqueLicenses } from "@/lib/mock-data"; import { ModelFilters as ModelFiltersType, Model } from "@/types"; import { ModelTable } from "@/components/models/ModelTable"; @@ -11,142 +12,106 @@ import { ModelTableSkeleton, ModelCardSkeleton } from "@/components/ui/Skeletons export default function ModelsPage() { const [filters, setFilters] = useState({}); const [view, setView] = useState<"table" | "cards">("table"); + const [models, setModels] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isFetchingNext, setIsFetchingNext] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [totalModels, setTotalModels] = useState(0); + + const { ref, inView } = useInView({ + threshold: 0.1, + rootMargin: "200px", + }); + + const buildQueryString = useCallback((cursorStr?: string | null) => { + const params = new URLSearchParams(); + params.set("limit", "12"); + if (cursorStr) params.set("cursor", cursorStr); + if (filters.search) params.set("search", filters.search); + if (filters.provider) params.set("provider", filters.provider); + if (filters.modality) params.set("modality", filters.modality); + if (filters.license) params.set("license", filters.license); + return params.toString(); + }, [filters]); useEffect(() => { const controller = new AbortController(); + setIsLoading(true); + setNextCursor(null); - fetch("/api/models?limit=100", { signal: controller.signal }) + fetch(`/api/models?${buildQueryString()}`, { signal: controller.signal }) .then((res) => res.json()) .then((json) => { if (Array.isArray(json.data)) { - setModels(json.data as Model[]); + setModels(json.data); + setNextCursor(json.nextCursor || null); + setTotalModels(json.total || 0); } }) - .catch(() => { - setModels(mockModels); - }) + .catch(() => setModels(mockModels.slice(0, 12))) .finally(() => setIsLoading(false)); return () => controller.abort(); - }, []); + }, [buildQueryString]); - const filteredModels = useMemo(() => { - let result = [...models]; - - if (filters.search) { - const q = filters.search.toLowerCase(); - result = result.filter( - (m) => - m.name.toLowerCase().includes(q) || - m.provider?.name.toLowerCase().includes(q) || - m.tags.some((t) => t.toLowerCase().includes(q)) - ); - } - if (filters.provider) { - result = result.filter((m) => m.provider?.name === filters.provider); - } - if (filters.modality) { - result = result.filter((m) => m.modalities.includes(filters.modality!)); - } - if (filters.license) { - result = result.filter((m) => m.license === filters.license); - } - if (filters.isOpenSource) { - result = result.filter((m) => m.isOpenSource); + useEffect(() => { + if (inView && nextCursor && !isFetchingNext && !isLoading) { + setIsFetchingNext(true); + fetch(`/api/models?${buildQueryString(nextCursor)}`) + .then((res) => res.json()) + .then((json) => { + if (Array.isArray(json.data)) { + setModels((prev) => [...prev, ...json.data]); + setNextCursor(json.nextCursor || null); + } + }) + .finally(() => setIsFetchingNext(false)); } + }, [inView, nextCursor, isFetchingNext, isLoading, buildQueryString]); - return result; - }, [filters, models]); + const displayedModels = filters.isOpenSource ? models.filter(m => m.isOpenSource) : models; return (
- {/* Header */}
-

- Models Directory -

-

- Browse and compare {mockModels.length} AI models across {getUniqueProviders().length} providers -

+

Models Directory

+

Browse and compare AI models

- -
- {/* Filters */}
- -
- - {/* Results */} -
-

- {filteredModels.length} of {mockModels.length} models -

+
{isLoading ? ( - view === "table" ? ( - - ) : ( -
- -
- ) + view === "table" ? :
) : view === "table" ? ( - + ) : (
- {filteredModels.map((model, idx) => ( - - ))} + {displayedModels.map((model, idx) => )}
)} - {!isLoading && filteredModels.length === 0 && ( -
-

- No models match your filters. -

+ {!isLoading && displayedModels.length > 0 && ( +
+ {isFetchingNext ? ( +
Loading more...
+ ) : !nextCursor ? ( +

End of directory

+ ) : null}
)}
); -} +} \ No newline at end of file diff --git a/src/components/models/ModelTable.tsx b/src/components/models/ModelTable.tsx index 7afffea..8392469 100644 --- a/src/components/models/ModelTable.tsx +++ b/src/components/models/ModelTable.tsx @@ -96,7 +96,9 @@ export function ModelTable({ models, showRank = true }: ModelTableProps) { Input $/M Output $/M GPQA - Modalities + + Modalities + License @@ -158,9 +160,8 @@ export function ModelTable({ models, showRank = true }: ModelTableProps) {
{model.modalities.map((modality: string) => ( - + {modalityIcons[modality] || "❓"} - {modality} ))}