From 4bd30f701c098af3bae3162862044da351a88d48 Mon Sep 17 00:00:00 2001 From: Pablo Voorvaart Date: Thu, 8 Aug 2024 16:48:21 +0900 Subject: [PATCH] infinite scroll --- .../videos/components/ArchiveVideos.tsx | 60 +++++++--- .../videos/components/pagination.tsx | 33 ++++++ packages/app/components/misc/Videos.tsx | 3 +- packages/app/lib/actions/events.ts | 1 + packages/app/lib/hooks/useSearchParams.ts | 7 +- packages/app/lib/services/eventService.tsx | 11 +- .../app/lib/services/organizationService.tsx | 11 +- packages/app/lib/services/sessionService.ts | 112 +++++++++++++++++- 8 files changed, 203 insertions(+), 35 deletions(-) diff --git a/packages/app/app/[organization]/videos/components/ArchiveVideos.tsx b/packages/app/app/[organization]/videos/components/ArchiveVideos.tsx index aa6c19635..4e1a52483 100644 --- a/packages/app/app/[organization]/videos/components/ArchiveVideos.tsx +++ b/packages/app/app/[organization]/videos/components/ArchiveVideos.tsx @@ -1,9 +1,13 @@ -import { fetchAllSessions } from '@/lib/data'; +'use client'; import Videos from '@/components/misc/Videos'; -import { FileQuestion } from 'lucide-react'; +import { FileQuestion, VideoOff } from 'lucide-react'; import Pagination from './pagination'; +import { useEffect, useState } from 'react'; +import { IExtendedSession, IPagination } from '@/lib/types'; +import { fetchAllSessions } from '@/lib/services/sessionService'; +import ArchiveVideoSkeleton from '../../../[organization]/livestream/components/ArchiveVideosSkeleton'; -const ArchiveVideos = async ({ +const ArchiveVideos = ({ organizationSlug, event, searchQuery, @@ -14,17 +18,41 @@ const ArchiveVideos = async ({ searchQuery?: string; page?: number; }) => { - const videos = await fetchAllSessions({ - organizationSlug, - event: event, - limit: 12, - onlyVideos: true, - published: true, - searchQuery, - page: Number(page || 1), + const [isLoading, setIsLoading] = useState(false); + const [videos, setVideos] = useState([]); + const [currentSearchQuery, setCurrentSearchQuery] = useState(''); + const [pagination, setPagination] = useState({ + currentPage: 1, + totalPages: 0, + totalItems: 0, + limit: 0, }); + useEffect(() => { + setIsLoading(true); + fetchAllSessions({ + organizationSlug, + event: event, + limit: 12, + onlyVideos: true, + published: true, + searchQuery, + page: Number(page || 1), + }) + .then((data) => { + if (searchQuery && searchQuery !== currentSearchQuery) { + setVideos(data.sessions); + setCurrentSearchQuery(searchQuery); + } else { + setVideos([...videos, ...data.sessions]); + } + setPagination(data.pagination); + }) + .finally(() => { + setIsLoading(false); + }); + }, [organizationSlug, event, searchQuery, page]); - if (videos.pagination.totalItems === 0) { + if (Videos.length === 0) { return (
@@ -37,8 +65,12 @@ const ArchiveVideos = async ({ return ( <> - - + {isLoading ? ( + + ) : ( + + )} + ); }; diff --git a/packages/app/app/[organization]/videos/components/pagination.tsx b/packages/app/app/[organization]/videos/components/pagination.tsx index 2688fb6b6..c53f683e8 100644 --- a/packages/app/app/[organization]/videos/components/pagination.tsx +++ b/packages/app/app/[organization]/videos/components/pagination.tsx @@ -5,12 +5,45 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import React, { useState } from 'react'; import { LuArrowLeft, LuArrowRight } from 'react-icons/lu'; +import { useEffect } from 'react'; const Pagination = (props: IPagination) => { const [jumpPage, setJumpPage] = useState(props.currentPage); const { handleTermChange, searchParams } = useSearchParams(); const currentPage = Number(searchParams.get('page')) || 1; + // trigger when reaching bottom of the page + useEffect(() => { + console.log( + window.innerHeight, + document.documentElement.scrollTop, + document.documentElement.offsetHeight + ); + const handleScroll = () => { + console.log( + window.innerHeight + document.documentElement.scrollTop, + document.documentElement.offsetHeight + ); + + if ( + window.innerHeight + document.documentElement.scrollTop + 1 < + document.documentElement.offsetHeight + ) { + return; + } + if (currentPage < props.totalPages) { + handleTermChange([ + { + key: 'page', + value: (currentPage + 1).toString(), + }, + ]); + } + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [currentPage, props.totalPages, handleTermChange]); + return (
diff --git a/packages/app/components/misc/Videos.tsx b/packages/app/components/misc/Videos.tsx index 00e48170c..11af4b9ed 100644 --- a/packages/app/components/misc/Videos.tsx +++ b/packages/app/components/misc/Videos.tsx @@ -1,8 +1,9 @@ +'use client'; import { IExtendedSession } from '@/lib/types'; import VideoCardWithMenu from './VideoCard/VideoCardWithMenu'; import { Suspense } from 'react'; import { Card, CardHeader, CardDescription } from '@/components/ui/card'; -export default async function VideoGrid({ +export default function VideoGrid({ videos, OrganizationSlug, maxVideos, diff --git a/packages/app/lib/actions/events.ts b/packages/app/lib/actions/events.ts index 13f16f561..d336ae8d0 100644 --- a/packages/app/lib/actions/events.ts +++ b/packages/app/lib/actions/events.ts @@ -12,6 +12,7 @@ import { IEvent } from 'streameth-new-server/src/interfaces/event.interface'; import { revalidatePath } from 'next/cache'; import GoogleSheetService from '@/lib/services/googleSheetService'; import GoogleDriveService from '@/lib/services/googleDriveService'; + export const createEventAction = async ({ event }: { event: IEvent }) => { const authToken = cookies().get('user-session')?.value; if (!authToken) { diff --git a/packages/app/lib/hooks/useSearchParams.ts b/packages/app/lib/hooks/useSearchParams.ts index e63c929fe..bd66f9570 100644 --- a/packages/app/lib/hooks/useSearchParams.ts +++ b/packages/app/lib/hooks/useSearchParams.ts @@ -10,7 +10,7 @@ interface ITerm { } const useSearchParams = () => { const pathname = usePathname(); - const { replace } = useRouter(); + const { push } = useRouter(); const searchParams = useNextSearchParams(); function handleTermChange(terms: ITerm[]) { @@ -21,9 +21,12 @@ const useSearchParams = () => { } else { params.delete(term.key); } - replace(`${pathname}?${params.toString()}`); + push(`${pathname}?${params.toString()}`, { + scroll: false, + }); } } + return { searchParams, handleTermChange, diff --git a/packages/app/lib/services/eventService.tsx b/packages/app/lib/services/eventService.tsx index 2a646bf9c..f7e04e3e6 100644 --- a/packages/app/lib/services/eventService.tsx +++ b/packages/app/lib/services/eventService.tsx @@ -30,14 +30,11 @@ export async function fetchEvents({ return []; } const response = await fetch( - `${apiUrl()}/events?organizationId=${organization._id}`, - { cache: 'no-store' } + `${apiUrl()}/events?organizationId=${organization._id}` ); data = (await response.json()).data ?? []; } else { - const response = await fetch(`${apiUrl()}/events`, { - cache: 'no-store', - }); + const response = await fetch(`${apiUrl()}/events`); data = (await response.json()).data ?? []; } @@ -69,9 +66,7 @@ export async function fetchEvent({ return null; } - const data = await fetch(`${apiUrl()}/events/${eventId ?? eventSlug}`, { - cache: 'no-store', - }); + const data = await fetch(`${apiUrl()}/events/${eventId ?? eventSlug}`); if (!data.ok) { return null; diff --git a/packages/app/lib/services/organizationService.tsx b/packages/app/lib/services/organizationService.tsx index c8559cbd7..cabf15ea0 100644 --- a/packages/app/lib/services/organizationService.tsx +++ b/packages/app/lib/services/organizationService.tsx @@ -17,10 +17,7 @@ export async function fetchOrganization({ const response = await fetch( `${apiUrl()}/organizations/${ organizationId ? organizationId : organizationSlug - }`, - { - cache: 'no-store', - } + }` ); const data = (await response.json()).data; @@ -33,9 +30,7 @@ export async function fetchOrganization({ export async function fetchOrganizations(): Promise { try { - const response = await fetch(`${apiUrl()}/organizations`, { - cache: 'no-store', - }); + const response = await fetch(`${apiUrl()}/organizations`); return (await response.json()).data ?? []; } catch (e) { console.log(e); @@ -166,7 +161,6 @@ export async function fetchOrganizationMembers({ 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, - cache: 'no-store', } ); @@ -231,7 +225,6 @@ export async function fetchOrganizationSocials({ 'Content-Type': 'application/json', Authorization: `Bearer ${authToken}`, }, - cache: 'no-store', } ); const data = (await response.json()).data; diff --git a/packages/app/lib/services/sessionService.ts b/packages/app/lib/services/sessionService.ts index e7d0845da..3fb7e4a27 100644 --- a/packages/app/lib/services/sessionService.ts +++ b/packages/app/lib/services/sessionService.ts @@ -1,10 +1,120 @@ import { ISessionModel } from 'streameth-new-server/src/interfaces/session.interface'; -import { IExtendedSession } from '../types'; +import { IExtendedSession, IPagination } from '../types'; import { apiUrl } from '@/lib/utils/utils'; import { Livepeer } from 'livepeer'; import { ISession } from 'streameth-new-server/src/interfaces/session.interface'; import { revalidatePath } from 'next/cache'; import { Asset } from 'livepeer/models/components'; +import FuzzySearch from 'fuzzy-search'; + +interface ApiParams { + event?: string; + organization?: string; + stageId?: string; + page?: number; + size?: number; + onlyVideos?: boolean; + published?: boolean; + speakerIds?: string[]; // Assuming speakerIds is an array of strings + date?: Date; + type?: string; +} + +function constructApiUrl(baseUrl: string, params: ApiParams): string { + const queryParams = Object.entries(params) + .filter(([_, value]) => value !== undefined && value !== null) + .map(([key, value]) => { + const formattedValue = Array.isArray(value) ? value.join(',') : value; + return `${encodeURIComponent(key)}=${encodeURIComponent(formattedValue)}`; + }) + .join('&'); + return `${baseUrl}?${queryParams}`; +} + +export async function fetchAllSessions({ + event, + organizationSlug, + stageId, + speakerIds, + onlyVideos, + published, + page = 1, + limit, + searchQuery = '', + type, +}: { + event?: string; + organizationSlug?: string; + stageId?: string; + speakerIds?: string[]; + onlyVideos?: boolean; + published?: boolean; + page?: number; + limit?: number; + searchQuery?: string; + type?: string; +}): Promise<{ + sessions: IExtendedSession[]; + pagination: IPagination; +}> { + const params: ApiParams = { + event, + stageId, + organization: organizationSlug, + page, + size: searchQuery ? 0 : limit, + onlyVideos, + published, + speakerIds, + type, + }; + + const response = await fetch( + constructApiUrl(`${apiUrl()}/sessions`, params), + { + cache: 'no-store', + } + ); + const a = await response.json(); + const allSessions = a.data; + if (searchQuery) { + const normalizedQuery = searchQuery.toLowerCase(); + const fuzzySearch = new FuzzySearch( + allSessions?.sessions, + ['name', 'description', 'speakers.name'], + { + caseSensitive: false, + sort: true, + } + ); + + allSessions.sessions = fuzzySearch.search(normalizedQuery); + } + + // Calculate total items and total pages + const totalItems = searchQuery + ? allSessions.sessions.length + : allSessions.totalDocuments; + const totalPages = limit ? Math.ceil(totalItems / limit) : 1; + + // Implement manual pagination for fuzzy search + const startIndex = (page - 1) * limit!; + const endIndex = startIndex + limit!; + const paginatedSessions = allSessions.sessions.slice(startIndex, endIndex); + + // Return paginated data and pagination metadata + return { + sessions: searchQuery ? paginatedSessions : allSessions.sessions, + pagination: allSessions?.pagination + ? allSessions.pagination + : { + currentPage: page, + totalPages, + totalItems, + limit, + }, + }; +} export const createSession = async ({ session,