From ec1b131c3c439bca9ff730aeac0cf099add87953 Mon Sep 17 00:00:00 2001 From: Guus van Meerveld Date: Wed, 20 Mar 2024 14:24:44 +0100 Subject: [PATCH] search: added infinite scrolling support --- package.json | 1 + public/.gitignore | 3 +- src/app/results/Loading.tsx | 18 +++++ src/app/results/Playlist.tsx | 9 +-- src/app/results/Search.tsx | 76 ++++++++++++++----- src/app/results/Video.tsx | 9 +-- src/client/adapters/invidious/index.ts | 8 +- src/client/adapters/invidious/transformer.ts | 4 +- src/client/adapters/piped/index.ts | 7 +- src/client/adapters/piped/transformer.ts | 4 +- .../adapters/piped/typings/search/index.ts | 2 +- src/client/index.ts | 5 +- src/client/typings/search/index.ts | 7 +- src/client/typings/search/options.ts | 2 +- src/components/Search.tsx | 10 +-- src/components/Video.tsx | 11 +-- src/hooks/useClient.ts | 4 +- src/utils/videoSize.ts | 6 ++ yarn.lock | 5 ++ 19 files changed, 130 insertions(+), 61 deletions(-) create mode 100644 src/app/results/Loading.tsx create mode 100644 src/utils/videoSize.ts diff --git a/package.json b/package.json index 4249ff0..c49fc7d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react": "^18", "react-dom": "^18", "react-icons": "^5.0.1", + "reactjs-visibility": "^0.1.4", "use-debounce": "^10.0.0", "zod": "^3.22.4", "zustand": "^4.5.2" diff --git a/public/.gitignore b/public/.gitignore index b55b34a..16eafcf 100644 --- a/public/.gitignore +++ b/public/.gitignore @@ -1 +1,2 @@ -/**.js \ No newline at end of file +/**.js +/**.js.map \ No newline at end of file diff --git a/src/app/results/Loading.tsx b/src/app/results/Loading.tsx new file mode 100644 index 0000000..ac50179 --- /dev/null +++ b/src/app/results/Loading.tsx @@ -0,0 +1,18 @@ +import { Component } from "@/typings/component"; +import { CircularProgress } from "@nextui-org/progress"; +import { useVisibility } from "reactjs-visibility"; + +export const Loading: Component<{ + isFetching: boolean; + onVisible: (visiblity: boolean) => void; +}> = ({ onVisible, isFetching }) => { + const { ref } = useVisibility({ + onChangeVisibility: onVisible + }); + + return ( +
+ {isFetching && } +
+ ); +}; diff --git a/src/app/results/Playlist.tsx b/src/app/results/Playlist.tsx index 3a91f02..726d931 100644 --- a/src/app/results/Playlist.tsx +++ b/src/app/results/Playlist.tsx @@ -6,19 +6,14 @@ import { Card, CardBody } from "@nextui-org/card"; import { Image } from "@nextui-org/image"; import NextLink from "next/link"; import NextImage from "next/image"; -import { useMemo } from "react"; import { Link } from "@nextui-org/link"; +import { videoSize } from "@/utils/videoSize"; export const Playlist: Component<{ data: PlaylistProps }> = ({ data }) => { const url = `/playlist/${data.id}`; const channelUrl = `/channel/${data.author.id}`; - const videoSize = 200; - const aspectRatio = 16 / 9; - - const [width, height] = useMemo(() => { - return [videoSize * aspectRatio, videoSize]; - }, [videoSize]); + const [width, height] = videoSize([16, 9], 30); return ( diff --git a/src/app/results/Search.tsx b/src/app/results/Search.tsx index e3676e4..6523882 100644 --- a/src/app/results/Search.tsx +++ b/src/app/results/Search.tsx @@ -4,7 +4,7 @@ import { Search as SearchInput } from "@/components/Search"; import { useClient } from "@/hooks/useClient"; import { Component } from "@/typings/component"; import { Spacer } from "@nextui-org/spacer"; -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; import { Channel } from "./Channel"; import { Container } from "@/components/Container"; @@ -12,6 +12,10 @@ import { LoadingPage } from "@/components/LoadingPage"; import { Button } from "@nextui-org/button"; import { Video } from "./Video"; import { Playlist } from "./Playlist"; +import { Fragment, useCallback } from "react"; +import { CircularProgress } from "@nextui-org/progress"; +import { useVisibility } from "reactjs-visibility"; +import { Loading } from "./Loading"; export const Search: Component = () => { const searchParams = useSearchParams(); @@ -20,23 +24,41 @@ export const Search: Component = () => { const client = useClient(); - const { isLoading, error, refetch, data } = useQuery({ + const { + data, + error, + fetchNextPage, + hasNextPage, + refetch, + isFetching, + isFetchingNextPage + } = useInfiniteQuery({ queryKey: ["search", query], - queryFn: () => { - if (query === null) return; - - return client.getSearch(query); - } + queryFn: async ({ pageParam }) => { + return await client.getSearch(query ?? "", { pageParam: pageParam }); + }, + enabled: query !== null, + initialPageParam: "", + getNextPageParam: (lastPage, pages) => lastPage.nextCursor }); - const results = data ?? []; + const handleUserReachedPageEnd = useCallback( + (visiblity: boolean) => { + console.log(visiblity); + + console.log(visiblity, !isFetchingNextPage, hasNextPage); + + if (visiblity && !isFetchingNextPage) fetchNextPage(); + }, + [hasNextPage, isFetchingNextPage] + ); return ( <> - {isLoading && } + {isFetching && } {error && (
@@ -52,19 +74,33 @@ export const Search: Component = () => {
)}
- {results.length != 0 && - results.map((result) => { - switch (result.type) { - case "channel": - return ; + {data?.pages.map((page, i) => { + return ( + + {page.items.map((result) => { + switch (result.type) { + case "channel": + return ; + + case "video": + return + ); + })} - case "video": - return
diff --git a/src/app/results/Video.tsx b/src/app/results/Video.tsx index 9667559..daba460 100644 --- a/src/app/results/Video.tsx +++ b/src/app/results/Video.tsx @@ -12,17 +12,13 @@ import formatUploadedTime from "@/utils/formatUploadedTime"; import { Link } from "@nextui-org/link"; import NextLink from "next/link"; import formatDuration from "@/utils/formatDuration"; +import { videoSize } from "@/utils/videoSize"; export const Video: Component<{ data: VideoProps }> = ({ data }) => { const url = `/watch?v=${data.id}`; const channelUrl = `/channel/${data.author.id}`; - const videoSize = 200; - const aspectRatio = 16 / 9; - - const [width, height] = useMemo(() => { - return [videoSize * aspectRatio, videoSize]; - }, [videoSize]); + const [width, height] = videoSize([16, 9], 30); return ( @@ -65,6 +61,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => { height={64} src={data.author.avatar} alt={data.author.name} + className="rounded-full" as={NextImage} unoptimized /> diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts index 66722d4..f222836 100644 --- a/src/client/adapters/invidious/index.ts +++ b/src/client/adapters/invidious/index.ts @@ -96,10 +96,14 @@ const adapter: Adapter = { return getSearchSuggestions(url, query).then(Transformer.suggestions); }, async getSearch(query, options) { - return getSearch(url, query, { - page: options?.page, + const page = options?.pageParam ? parseInt(options.pageParam) : 1; + + const items = await getSearch(url, query, { + page: page, type: options?.type }).then(Transformer.search); + + return { items: items, nextCursor: (page + 1).toString() }; } }; } diff --git a/src/client/adapters/invidious/transformer.ts b/src/client/adapters/invidious/transformer.ts index d4a389c..10579ee 100644 --- a/src/client/adapters/invidious/transformer.ts +++ b/src/client/adapters/invidious/transformer.ts @@ -3,7 +3,7 @@ import { Suggestions } from "@/client/typings/search/suggestions"; import { ChannelResult, PlaylistResult, - SearchResults, + SearchItems, VideoResult } from "@/client/typings/search"; @@ -56,7 +56,7 @@ export default class Transformer { return data.suggestions; } - public static search(data: InvidiousSearch): SearchResults { + public static search(data: InvidiousSearch): SearchItems { return data.map((result) => { switch (result.type) { case "video": diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts index c1c77cb..48e4755 100644 --- a/src/client/adapters/piped/index.ts +++ b/src/client/adapters/piped/index.ts @@ -115,9 +115,10 @@ const adapter: Adapter = { break; } - return getSearch(url, query, { filter: filter }).then( - Transformer.search - ); + return getSearch(url, query, { + filter: filter, + nextpage: options?.pageParam + }).then(Transformer.search); } }; } diff --git a/src/client/adapters/piped/transformer.ts b/src/client/adapters/piped/transformer.ts index 90870db..f171d6a 100644 --- a/src/client/adapters/piped/transformer.ts +++ b/src/client/adapters/piped/transformer.ts @@ -45,7 +45,7 @@ export default class Transformer { } public static search(data: PipedSearch): SearchResults { - return data.items.map((result) => { + const items = data.items.map((result) => { switch (result.type) { case "stream": const video: VideoResult = { @@ -92,5 +92,7 @@ export default class Transformer { return playlist; } }); + + return { items, nextCursor: data.nextpage }; } } diff --git a/src/client/adapters/piped/typings/search/index.ts b/src/client/adapters/piped/typings/search/index.ts index d865450..cbdee41 100644 --- a/src/client/adapters/piped/typings/search/index.ts +++ b/src/client/adapters/piped/typings/search/index.ts @@ -35,7 +35,7 @@ export const SearchModel = z.object({ .union([VideoResultModel, ChannelResultModel, PlaylistResultModel]) .array(), nextpage: z.string(), - suggestion: z.string(), + suggestion: z.string().nullable(), corrected: z.boolean() }); diff --git a/src/client/index.ts b/src/client/index.ts index 2bb6b79..0199339 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -68,8 +68,11 @@ export default class Client { ): Promise { const adapter = this.getBestAdapter(); + const pageParam = + options?.pageParam?.length === 0 ? undefined : options?.pageParam; + return await adapter.getSearch(query, { - page: options?.page ?? 1, + pageParam: pageParam, type: options?.type ?? "all" }); } diff --git a/src/client/typings/search/index.ts b/src/client/typings/search/index.ts index 52c729f..1c4f3cb 100644 --- a/src/client/typings/search/index.ts +++ b/src/client/typings/search/index.ts @@ -30,4 +30,9 @@ export interface PlaylistResult { }[]; } -export type SearchResults = (VideoResult | ChannelResult | PlaylistResult)[]; +export type SearchItems = (VideoResult | ChannelResult | PlaylistResult)[]; + +export interface SearchResults { + items: SearchItems; + nextCursor: string; +} diff --git a/src/client/typings/search/options.ts b/src/client/typings/search/options.ts index eb0f25d..f1ce007 100644 --- a/src/client/typings/search/options.ts +++ b/src/client/typings/search/options.ts @@ -1,5 +1,5 @@ export interface SearchOptions { - page?: number; + pageParam?: string; type?: SearchType; } diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 34bbefe..5bd1255 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -4,15 +4,15 @@ import { useClient } from "@/hooks/useClient"; import { Component } from "@/typings/component"; import { Autocomplete, AutocompleteItem } from "@nextui-org/autocomplete"; import { useQuery } from "@tanstack/react-query"; -import { FormEventHandler, useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { FiSearch as SearchIcon } from "react-icons/fi"; import { useRouter } from "next/navigation"; -export const Search: Component<{ initialQueryValue?: string }> = ({ - initialQueryValue -}) => { +export const Search: Component<{ + initialQueryValue?: string; +}> = ({ initialQueryValue }) => { const client = useClient(); const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? ""); @@ -70,7 +70,7 @@ export const Search: Component<{ initialQueryValue?: string }> = ({ required type="text" label="Search" - variant="bordered" + variant="flat" placeholder="Search for videos" > {(suggestion) => ( diff --git a/src/components/Video.tsx b/src/components/Video.tsx index 1d1ac40..d08d458 100644 --- a/src/components/Video.tsx +++ b/src/components/Video.tsx @@ -12,18 +12,13 @@ import { Tooltip } from "@nextui-org/tooltip"; import { ContextMenuItem } from "@/typings/contextMenu"; import NextImage from "next/image"; -import { useMemo } from "react"; +import { videoSize } from "@/utils/videoSize"; export const Video: Component<{ data: VideoProps }> = ({ data }) => { const url = `/watch?v=${data.id}`; const channelUrl = `/channel/${data.author.id}`; - const videoSize = 400; - const aspectRatio = 16 / 9; - - const [width, height] = useMemo(() => { - return [videoSize * aspectRatio, videoSize]; - }, [videoSize]); + const [width, height] = videoSize([16, 9], 40); const menuItems: ContextMenuItem[] = [ { title: "Go to video", key: "gotoVideo", href: url }, @@ -69,7 +64,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => { width={width} unoptimized alt={data.title} - className="object-contain aspect-video" + className="object-contain aspect" src={data.thumbnail} /> diff --git a/src/hooks/useClient.ts b/src/hooks/useClient.ts index ce4efd2..d58e913 100644 --- a/src/hooks/useClient.ts +++ b/src/hooks/useClient.ts @@ -7,8 +7,8 @@ export const useClient = () => { const [client] = useState( () => new Client([ - { baseUrl: "https://invidious.drgns.space", type: ApiType.Invidious } - // { baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped } + // { baseUrl: "https://invidious.drgns.space", type: ApiType.Invidious } + { baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped } ]) ); diff --git a/src/utils/videoSize.ts b/src/utils/videoSize.ts new file mode 100644 index 0000000..7cece18 --- /dev/null +++ b/src/utils/videoSize.ts @@ -0,0 +1,6 @@ +export const videoSize = ( + aspectRatio: [number, number], + size: number +): [number, number] => { + return [aspectRatio[0] * size, aspectRatio[1] * size]; +}; diff --git a/yarn.lock b/yarn.lock index ec2f04a..322be98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5680,6 +5680,11 @@ react@^18: dependencies: loose-envify "^1.1.0" +reactjs-visibility@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/reactjs-visibility/-/reactjs-visibility-0.1.4.tgz#843a50cf8c156109fb9ebf855ad60f86b699f10d" + integrity sha512-r2NZUFt8kXcay3/oIC+iiP8I/woTWTtQ7CW/Q2aBfJtHertGTN2Qpg68scJpSA7oP4r3nVtXtUOffHJMuICbMQ== + read-cache@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"