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

nextui
Guus van Meerveld 2 months ago
parent c850857768
commit ec1b131c3c

@ -21,6 +21,7 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-icons": "^5.0.1", "react-icons": "^5.0.1",
"reactjs-visibility": "^0.1.4",
"use-debounce": "^10.0.0", "use-debounce": "^10.0.0",
"zod": "^3.22.4", "zod": "^3.22.4",
"zustand": "^4.5.2" "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 { Image } from "@nextui-org/image";
import NextLink from "next/link"; import NextLink from "next/link";
import NextImage from "next/image"; import NextImage from "next/image";
import { useMemo } from "react";
import { Link } from "@nextui-org/link"; import { Link } from "@nextui-org/link";
import { videoSize } from "@/utils/videoSize";
export const Playlist: Component<{ data: PlaylistProps }> = ({ data }) => { export const Playlist: Component<{ data: PlaylistProps }> = ({ data }) => {
const url = `/playlist/${data.id}`; const url = `/playlist/${data.id}`;
const channelUrl = `/channel/${data.author.id}`; const channelUrl = `/channel/${data.author.id}`;
const videoSize = 200; const [width, height] = videoSize([16, 9], 30);
const aspectRatio = 16 / 9;
const [width, height] = useMemo(() => {
return [videoSize * aspectRatio, videoSize];
}, [videoSize]);
return ( return (
<NextLink href={url}> <NextLink href={url}>

@ -4,7 +4,7 @@ import { Search as SearchInput } from "@/components/Search";
import { useClient } from "@/hooks/useClient"; import { useClient } from "@/hooks/useClient";
import { Component } from "@/typings/component"; import { Component } from "@/typings/component";
import { Spacer } from "@nextui-org/spacer"; import { Spacer } from "@nextui-org/spacer";
import { useQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Channel } from "./Channel"; import { Channel } from "./Channel";
import { Container } from "@/components/Container"; import { Container } from "@/components/Container";
@ -12,6 +12,10 @@ import { LoadingPage } from "@/components/LoadingPage";
import { Button } from "@nextui-org/button"; import { Button } from "@nextui-org/button";
import { Video } from "./Video"; import { Video } from "./Video";
import { Playlist } from "./Playlist"; 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 = () => { export const Search: Component = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -20,23 +24,41 @@ export const Search: Component = () => {
const client = useClient(); const client = useClient();
const { isLoading, error, refetch, data } = useQuery({ const {
data,
error,
fetchNextPage,
hasNextPage,
refetch,
isFetching,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ["search", query], queryKey: ["search", query],
queryFn: () => { queryFn: async ({ pageParam }) => {
if (query === null) return; return await client.getSearch(query ?? "", { pageParam: pageParam });
},
return client.getSearch(query); 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 ( return (
<> <>
<Container> <Container>
<SearchInput initialQueryValue={query ?? undefined} /> <SearchInput initialQueryValue={query ?? undefined} />
<Spacer y={4} /> <Spacer y={4} />
{isLoading && <LoadingPage />} {isFetching && <LoadingPage />}
{error && ( {error && (
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@ -52,19 +74,33 @@ export const Search: Component = () => {
</div> </div>
)} )}
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{results.length != 0 && {data?.pages.map((page, i) => {
results.map((result) => { return (
switch (result.type) { <Fragment key={i}>
case "channel": {page.items.map((result) => {
return <Channel key={result.id} data={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": <Loading
return <Video key={result.id} data={result} />; isFetching={isFetchingNextPage}
onVisible={handleUserReachedPageEnd}
/>
case "playlist": {/* {!isFetching && !isFetchingNextPage && !error && (
return <Playlist key={result.id} data={result} />; <Button onClick={() => fetchNextPage()}>Load more</Button>
} )} */}
})}
</div> </div>
</Container> </Container>
</> </>

@ -12,17 +12,13 @@ import formatUploadedTime from "@/utils/formatUploadedTime";
import { Link } from "@nextui-org/link"; import { Link } from "@nextui-org/link";
import NextLink from "next/link"; import NextLink from "next/link";
import formatDuration from "@/utils/formatDuration"; import formatDuration from "@/utils/formatDuration";
import { videoSize } from "@/utils/videoSize";
export const Video: Component<{ data: VideoProps }> = ({ data }) => { export const Video: Component<{ data: VideoProps }> = ({ data }) => {
const url = `/watch?v=${data.id}`; const url = `/watch?v=${data.id}`;
const channelUrl = `/channel/${data.author.id}`; const channelUrl = `/channel/${data.author.id}`;
const videoSize = 200; const [width, height] = videoSize([16, 9], 30);
const aspectRatio = 16 / 9;
const [width, height] = useMemo(() => {
return [videoSize * aspectRatio, videoSize];
}, [videoSize]);
return ( return (
<NextLink href={url}> <NextLink href={url}>
@ -65,6 +61,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => {
height={64} height={64}
src={data.author.avatar} src={data.author.avatar}
alt={data.author.name} alt={data.author.name}
className="rounded-full"
as={NextImage} as={NextImage}
unoptimized unoptimized
/> />

@ -96,10 +96,14 @@ const adapter: Adapter = {
return getSearchSuggestions(url, query).then(Transformer.suggestions); return getSearchSuggestions(url, query).then(Transformer.suggestions);
}, },
async getSearch(query, options) { async getSearch(query, options) {
return getSearch(url, query, { const page = options?.pageParam ? parseInt(options.pageParam) : 1;
page: options?.page,
const items = await getSearch(url, query, {
page: page,
type: options?.type type: options?.type
}).then(Transformer.search); }).then(Transformer.search);
return { items: items, nextCursor: (page + 1).toString() };
} }
}; };
} }

@ -3,7 +3,7 @@ import { Suggestions } from "@/client/typings/search/suggestions";
import { import {
ChannelResult, ChannelResult,
PlaylistResult, PlaylistResult,
SearchResults, SearchItems,
VideoResult VideoResult
} from "@/client/typings/search"; } from "@/client/typings/search";
@ -56,7 +56,7 @@ export default class Transformer {
return data.suggestions; return data.suggestions;
} }
public static search(data: InvidiousSearch): SearchResults { public static search(data: InvidiousSearch): SearchItems {
return data.map((result) => { return data.map((result) => {
switch (result.type) { switch (result.type) {
case "video": case "video":

@ -115,9 +115,10 @@ const adapter: Adapter = {
break; break;
} }
return getSearch(url, query, { filter: filter }).then( return getSearch(url, query, {
Transformer.search filter: filter,
); nextpage: options?.pageParam
}).then(Transformer.search);
} }
}; };
} }

@ -45,7 +45,7 @@ export default class Transformer {
} }
public static search(data: PipedSearch): SearchResults { public static search(data: PipedSearch): SearchResults {
return data.items.map((result) => { const items = data.items.map((result) => {
switch (result.type) { switch (result.type) {
case "stream": case "stream":
const video: VideoResult = { const video: VideoResult = {
@ -92,5 +92,7 @@ export default class Transformer {
return playlist; return playlist;
} }
}); });
return { items, nextCursor: data.nextpage };
} }
} }

@ -35,7 +35,7 @@ export const SearchModel = z.object({
.union([VideoResultModel, ChannelResultModel, PlaylistResultModel]) .union([VideoResultModel, ChannelResultModel, PlaylistResultModel])
.array(), .array(),
nextpage: z.string(), nextpage: z.string(),
suggestion: z.string(), suggestion: z.string().nullable(),
corrected: z.boolean() corrected: z.boolean()
}); });

@ -68,8 +68,11 @@ export default class Client {
): Promise<SearchResults> { ): Promise<SearchResults> {
const adapter = this.getBestAdapter(); const adapter = this.getBestAdapter();
const pageParam =
options?.pageParam?.length === 0 ? undefined : options?.pageParam;
return await adapter.getSearch(query, { return await adapter.getSearch(query, {
page: options?.page ?? 1, pageParam: pageParam,
type: options?.type ?? "all" 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 { export interface SearchOptions {
page?: number; pageParam?: string;
type?: SearchType; type?: SearchType;
} }

@ -4,15 +4,15 @@ import { useClient } from "@/hooks/useClient";
import { Component } from "@/typings/component"; import { Component } from "@/typings/component";
import { Autocomplete, AutocompleteItem } from "@nextui-org/autocomplete"; import { Autocomplete, AutocompleteItem } from "@nextui-org/autocomplete";
import { useQuery } from "@tanstack/react-query"; 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 { useDebounce } from "use-debounce";
import { FiSearch as SearchIcon } from "react-icons/fi"; import { FiSearch as SearchIcon } from "react-icons/fi";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export const Search: Component<{ initialQueryValue?: string }> = ({ export const Search: Component<{
initialQueryValue initialQueryValue?: string;
}) => { }> = ({ initialQueryValue }) => {
const client = useClient(); const client = useClient();
const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? ""); const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? "");
@ -70,7 +70,7 @@ export const Search: Component<{ initialQueryValue?: string }> = ({
required required
type="text" type="text"
label="Search" label="Search"
variant="bordered" variant="flat"
placeholder="Search for videos" placeholder="Search for videos"
> >
{(suggestion) => ( {(suggestion) => (

@ -12,18 +12,13 @@ import { Tooltip } from "@nextui-org/tooltip";
import { ContextMenuItem } from "@/typings/contextMenu"; import { ContextMenuItem } from "@/typings/contextMenu";
import NextImage from "next/image"; import NextImage from "next/image";
import { useMemo } from "react"; import { videoSize } from "@/utils/videoSize";
export const Video: Component<{ data: VideoProps }> = ({ data }) => { export const Video: Component<{ data: VideoProps }> = ({ data }) => {
const url = `/watch?v=${data.id}`; const url = `/watch?v=${data.id}`;
const channelUrl = `/channel/${data.author.id}`; const channelUrl = `/channel/${data.author.id}`;
const videoSize = 400; const [width, height] = videoSize([16, 9], 40);
const aspectRatio = 16 / 9;
const [width, height] = useMemo(() => {
return [videoSize * aspectRatio, videoSize];
}, [videoSize]);
const menuItems: ContextMenuItem[] = [ const menuItems: ContextMenuItem[] = [
{ title: "Go to video", key: "gotoVideo", href: url }, { title: "Go to video", key: "gotoVideo", href: url },
@ -69,7 +64,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => {
width={width} width={width}
unoptimized unoptimized
alt={data.title} alt={data.title}
className="object-contain aspect-video" className="object-contain aspect"
src={data.thumbnail} src={data.thumbnail}
/> />

@ -7,8 +7,8 @@ export const useClient = () => {
const [client] = useState( const [client] = useState(
() => () =>
new Client([ new Client([
{ baseUrl: "https://invidious.drgns.space", type: ApiType.Invidious } // { baseUrl: "https://invidious.drgns.space", type: ApiType.Invidious }
// { baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped } { 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: dependencies:
loose-envify "^1.1.0" 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: read-cache@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"

Loading…
Cancel
Save