search: added infinite scrolling support
continuous-integration/drone/push Build is failing Details

nextui
Guus van Meerveld 1 month ago
parent c850857768
commit ec1b131c3c

@ -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"

3
public/.gitignore vendored

@ -1 +1,2 @@
/**.js
/**.js
/**.js.map

@ -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 (
<div ref={ref} className="flex items-center justify-center min-h-10">
{isFetching && <CircularProgress aria-label="Loading more items..." />}
</div>
);
};

@ -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 (
<NextLink href={url}>

@ -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 (
<>
<Container>
<SearchInput initialQueryValue={query ?? undefined} />
<Spacer y={4} />
{isLoading && <LoadingPage />}
{isFetching && <LoadingPage />}
{error && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
@ -52,19 +74,33 @@ export const Search: Component = () => {
</div>
)}
<div className="flex flex-col gap-4">
{results.length != 0 &&
results.map((result) => {
switch (result.type) {
case "channel":
return <Channel key={result.id} data={result} />;
{data?.pages.map((page, i) => {
return (
<Fragment key={i}>
{page.items.map((result) => {
switch (result.type) {
case "channel":
return <Channel key={result.id} data={result} />;
case "video":
return <Video key={result.id} data={result} />;
case "playlist":
return <Playlist key={result.id} data={result} />;
}
})}
</Fragment>
);
})}
case "video":
return <Video key={result.id} data={result} />;
<Loading
isFetching={isFetchingNextPage}
onVisible={handleUserReachedPageEnd}
/>
case "playlist":
return <Playlist key={result.id} data={result} />;
}
})}
{/* {!isFetching && !isFetchingNextPage && !error && (
<Button onClick={() => fetchNextPage()}>Load more</Button>
)} */}
</div>
</Container>
</>

@ -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 (
<NextLink href={url}>
@ -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
/>

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

@ -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":

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

@ -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 };
}
}

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

@ -68,8 +68,11 @@ export default class Client {
): Promise<SearchResults> {
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"
});
}

@ -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;
}

@ -1,5 +1,5 @@
export interface SearchOptions {
page?: number;
pageParam?: string;
type?: SearchType;
}

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

@ -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}
/>

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

@ -0,0 +1,6 @@
export const videoSize = (
aspectRatio: [number, number],
size: number
): [number, number] => {
return [aspectRatio[0] * size, aspectRatio[1] * size];
};

@ -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"

Loading…
Cancel
Save