diff --git a/package.json b/package.json index 8910c9f..50165a1 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "react": "^18", "react-dom": "^18", "react-icons": "^5.0.1", + "use-debounce": "^10.0.0", "zod": "^3.22.4" }, "devDependencies": { diff --git a/src/app/(trending)/VideoCard.tsx b/src/app/(trending)/VideoCard.tsx index f8370e9..add1bae 100644 --- a/src/app/(trending)/VideoCard.tsx +++ b/src/app/(trending)/VideoCard.tsx @@ -31,7 +31,7 @@ export const VideoCard: Component<{ data: TrendingVideo }> = ({ {video.title}

-

+

{video.author.name}

diff --git a/src/app/results/SearchPage.tsx b/src/app/results/SearchPage.tsx new file mode 100644 index 0000000..95ca165 --- /dev/null +++ b/src/app/results/SearchPage.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { Search } from "@/components/Search"; +import { Component } from "@/typings/component"; +import { useSearchParams } from "next/navigation"; + +export const SearchPage: Component = () => { + const searchParams = useSearchParams(); + + const query = searchParams.get("search_query"); + + return ( + <> + + + ); +}; diff --git a/src/app/results/page.tsx b/src/app/results/page.tsx new file mode 100644 index 0000000..0a49eb4 --- /dev/null +++ b/src/app/results/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { SearchPage } from "./SearchPage"; + +export default function Page() { + return ( + <> + + + + + ); +} diff --git a/src/app/watch/Watch.tsx b/src/app/watch/Watch.tsx new file mode 100644 index 0000000..83edc90 --- /dev/null +++ b/src/app/watch/Watch.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Component } from "@/typings/component"; +import { useSearchParams } from "next/navigation"; + +export const Watch: Component = () => { + const searchParams = useSearchParams(); + + const videoId = searchParams.get("v"); + + return <>; +}; diff --git a/src/app/watch/page.tsx b/src/app/watch/page.tsx new file mode 100644 index 0000000..4f7cbf9 --- /dev/null +++ b/src/app/watch/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import { Watch } from "./Watch"; + +export default function Page() { + return ( + <> + + + + + ); +} diff --git a/src/client/adapters/index.ts b/src/client/adapters/index.ts index 55d1587..f072ae5 100644 --- a/src/client/adapters/index.ts +++ b/src/client/adapters/index.ts @@ -1,7 +1,10 @@ -import { TrendingVideo } from "../typings/trending"; +import { Suggestions } from "@/client/typings/search/suggestions"; +import { TrendingVideo } from "@/client/typings/trending"; export interface ConnectedAdapter { getTrending(region: string): Promise; + + getSearchSuggestions(query: string): Promise; } export default interface Adapter { diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts index d23d6e0..6383650 100644 --- a/src/client/adapters/invidious/index.ts +++ b/src/client/adapters/invidious/index.ts @@ -1,14 +1,20 @@ import ky from "ky"; import Trending, { TrendingModel } from "./typings/trending"; +import Suggestions, { SuggestionsModel } from "./typings/search/suggestions"; + import Adapter, { ApiType } from "@/client/adapters"; + import Transformer from "./transformer"; -const apiPath = (path: string): string => `/api/v1/${path}`; +import path from "path"; + +const apiPath = (...paths: string[]): string => + path.join("api", "v1", ...paths); export type TrendingVideoType = "music" | "gaming" | "news" | "movies"; -export const getTrending = async ( +const getTrending = async ( baseUrl: string, region?: string, type?: TrendingVideoType @@ -32,6 +38,23 @@ export const getTrending = async ( return data; }; +const getSearchSuggestions = async ( + baseUrl: string, + query: string +): Promise => { + const url = new URL(apiPath("search", "suggestions"), baseUrl); + + const response = await ky.get(url, { + searchParams: { q: query } + }); + + const json = await response.json(); + + const data = SuggestionsModel.parse(json); + + return data; +}; + const adapter: Adapter = { apiType: ApiType.Invidious, @@ -39,6 +62,10 @@ const adapter: Adapter = { return { getTrending(region) { return getTrending(url, region).then(Transformer.trending); + }, + + getSearchSuggestions(query) { + return getSearchSuggestions(url, query).then(Transformer.suggestions); } }; } diff --git a/src/client/adapters/invidious/transformer.ts b/src/client/adapters/invidious/transformer.ts index 4bf506b..28e608d 100644 --- a/src/client/adapters/invidious/transformer.ts +++ b/src/client/adapters/invidious/transformer.ts @@ -1,6 +1,8 @@ import { TrendingVideo } from "@/client/typings/trending"; import InvidiousTrending from "./typings/trending"; +import InvidiousSuggestions from "./typings/search/suggestions"; +import { Suggestions } from "@/client/typings/search/suggestions"; export default class Transformer { public static trending(data: InvidiousTrending[]): TrendingVideo[] { @@ -28,4 +30,8 @@ export default class Transformer { }; }); } + + public static suggestions(data: InvidiousSuggestions): Suggestions { + return data.suggestions; + } } diff --git a/src/client/adapters/invidious/typings/search/suggestions.ts b/src/client/adapters/invidious/typings/search/suggestions.ts new file mode 100644 index 0000000..25c91b5 --- /dev/null +++ b/src/client/adapters/invidious/typings/search/suggestions.ts @@ -0,0 +1,10 @@ +import z from "zod"; + +export const SuggestionsModel = z.object({ + query: z.string(), + suggestions: z.string().array() +}); + +type Suggestions = z.infer; + +export default Suggestions; diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts index 1897a0a..d9d711c 100644 --- a/src/client/adapters/piped/index.ts +++ b/src/client/adapters/piped/index.ts @@ -1,3 +1,4 @@ +import z from "zod"; import ky from "ky"; import Adapter, { ApiType } from "@/client/adapters"; @@ -5,8 +6,9 @@ import Adapter, { ApiType } from "@/client/adapters"; import Trending, { TrendingModel } from "./typings/trending"; import Transformer from "./transformer"; +import { Suggestions } from "@/client/typings/search/suggestions"; -export const getTrending = async ( +const getTrending = async ( apiBaseUrl: string, region = "US" ): Promise => { @@ -23,6 +25,23 @@ export const getTrending = async ( return data; }; +const getSearchSuggestions = async ( + apiBaseUrl: string, + query: string +): Promise => { + const url = new URL("suggestions", apiBaseUrl); + + const response = await ky.get(url, { + searchParams: { query: query } + }); + + const json = await response.json(); + + const data = z.string().array().parse(json); + + return data; +}; + const adapter: Adapter = { apiType: ApiType.Piped, @@ -30,6 +49,10 @@ const adapter: Adapter = { return { getTrending(region) { return getTrending(url, region).then(Transformer.trending); + }, + + getSearchSuggestions(query) { + return getSearchSuggestions(url, query); } }; } diff --git a/src/client/index.ts b/src/client/index.ts index e8fc346..1b151c0 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -3,7 +3,8 @@ import { TrendingVideo } from "./typings/trending"; import InvidiousAdapter from "./adapters/invidious"; import PipedAdapter from "./adapters/piped"; -import Adapter, { ApiType } from "./adapters"; +import Adapter, { ApiType, ConnectedAdapter } from "./adapters"; +import { Suggestions } from "./typings/search/suggestions"; export interface RemoteApi { type: ApiType; @@ -24,7 +25,7 @@ export default class Client { this.apis = apis.map((api) => ({ ...api, score: 0 })); } - private getAdapterForApiType(apiType: ApiType): Adapter { + private findAdapterForApiType(apiType: ApiType): Adapter { const adapter = this.adapters.find((adapter) => adapter.apiType == apiType); if (adapter === undefined) @@ -39,11 +40,23 @@ export default class Client { return this.apis[randomIndex]; } - public async getTrending(region: string): Promise { + private getBestAdapter(): ConnectedAdapter { const api = this.getBestApi(); - const adapter = this.getAdapterForApiType(api.type); + const adapter = this.findAdapterForApiType(api.type); + + return adapter.connect(api.baseUrl); + } + + public async getTrending(region: string): Promise { + const adapter = this.getBestAdapter(); + + return await adapter.getTrending(region); + } + + public async getSearchSuggestions(query: string): Promise { + const adapter = this.getBestAdapter(); - return await adapter.connect(api.baseUrl).getTrending(region); + return await adapter.getSearchSuggestions(query); } } diff --git a/src/client/typings/search/suggestions.ts b/src/client/typings/search/suggestions.ts new file mode 100644 index 0000000..81f5d8e --- /dev/null +++ b/src/client/typings/search/suggestions.ts @@ -0,0 +1 @@ +export type Suggestions = string[]; diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 4e6fe74..415cb54 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -12,6 +12,7 @@ import { Button } from "@nextui-org/button"; import NextLink from "next/link"; import { usePathname } from "next/navigation"; +import { Search } from "./Search"; export const Nav: Component = () => { const navItems = [ diff --git a/src/components/Search.tsx b/src/components/Search.tsx new file mode 100644 index 0000000..e799786 --- /dev/null +++ b/src/components/Search.tsx @@ -0,0 +1,52 @@ +"use client"; + +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, useState } from "react"; +import { useDebounce } from "use-debounce"; + +export const Search: Component<{ initialQueryValue?: string }> = ({ + initialQueryValue +}) => { + const client = useClient(); + + const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? ""); + + const [searchQueryDebounced] = useDebounce(searchQuery, 500); + + const { isLoading, error, data } = useQuery({ + queryKey: ["search", "suggestions", searchQueryDebounced], + queryFn: () => client.getSearchSuggestions(searchQueryDebounced), + enabled: searchQueryDebounced.length !== 0 + }); + + const handleSubmit: FormEventHandler = (e) => { + // e.preventDefault(); + + console.log(searchQuery); + }; + + const suggestions = data ?? []; + + return ( + + {suggestions.map((suggestion) => ( + + {suggestion} + + ))} + + ); +}; diff --git a/yarn.lock b/yarn.lock index 0542599..ad3ffc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6446,6 +6446,11 @@ use-composed-ref@^1.3.0: resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== +use-debounce@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.0.tgz#5091b18d6c16292605f588bae3c0d2cfae756ff2" + integrity sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A== + use-isomorphic-layout-effect@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"