diff --git a/src/app/results/Channel.tsx b/src/app/results/Channel.tsx new file mode 100644 index 0000000..bf3e28e --- /dev/null +++ b/src/app/results/Channel.tsx @@ -0,0 +1,42 @@ +import { Component } from "@/typings/component"; + +import { ChannelResult as ChannelProps } from "@/client/typings/search"; +import { Card, CardBody } from "@nextui-org/card"; +import { Image } from "@nextui-org/image"; +import Link from "next/link"; +import NextImage from "next/image"; +import formatViewCount from "@/utils/formatViewCount"; + +export const Channel: Component<{ data: ChannelProps }> = ({ data }) => { + const url = `/channel/${data.id}`; + + const imageSize = 200; + + return ( + + + +
+ {data.name} + +
+

{data.name}

+
+

Subscribers: {formatViewCount(data.subscribers)}

+

Videos: {formatViewCount(data.videos)}

+
+

{data.description}

+
+
+
+
+ + ); +}; diff --git a/src/app/results/Search.tsx b/src/app/results/Search.tsx new file mode 100644 index 0000000..b22bf7f --- /dev/null +++ b/src/app/results/Search.tsx @@ -0,0 +1,71 @@ +"use client"; + +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 { useSearchParams } from "next/navigation"; +import { Channel } from "./Channel"; +import { Container } from "@/components/Container"; +import { LoadingPage } from "@/components/LoadingPage"; +import { Button } from "@nextui-org/button"; +import { Video } from "./Video"; + +export const Search: Component = () => { + const searchParams = useSearchParams(); + + const query = searchParams.get("search_query"); + + const client = useClient(); + + const { isLoading, error, refetch, data } = useQuery({ + queryKey: ["search", query], + queryFn: () => { + if (query === null) return; + + return client.getSearch(query); + } + }); + + const results = data ?? []; + + return ( + <> + + + + {isLoading && } + {error && ( +
+
+

+ An error occurred loading the search page +

+

{error.toString()}

+ + +
+
+ )} +
+ {results.length != 0 && + results.map((result) => { + switch (result.type) { + case "channel": + return ; + + case "video": + return
+
+ + ); +}; diff --git a/src/app/results/SearchPage.tsx b/src/app/results/SearchPage.tsx deleted file mode 100644 index ad86e71..0000000 --- a/src/app/results/SearchPage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"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/Video.tsx b/src/app/results/Video.tsx new file mode 100644 index 0000000..9081bdd --- /dev/null +++ b/src/app/results/Video.tsx @@ -0,0 +1,42 @@ +import { Component } from "@/typings/component"; +import { VideoResult as VideoProps } from "@/client/typings/search"; +import { Card, CardBody } from "@nextui-org/card"; +import { Image } from "@nextui-org/image"; + +import NextImage from "next/image"; +import { useMemo } from "react"; +import Link from "next/link"; + +export const Video: Component<{ data: VideoProps }> = ({ data }) => { + const url = `/watch?v=${data.id}`; + + const videoSize = 200; + const aspectRatio = 16 / 9; + + const [width, height] = useMemo(() => { + return [videoSize * aspectRatio, videoSize]; + }, [videoSize]); + + return ( + + + +
+ {data.title} +
+

{data.title}

+
+
+
+
+ + ); +}; diff --git a/src/app/results/page.tsx b/src/app/results/page.tsx index 0a49eb4..ce8fa53 100644 --- a/src/app/results/page.tsx +++ b/src/app/results/page.tsx @@ -1,11 +1,11 @@ import { Suspense } from "react"; -import { SearchPage } from "./SearchPage"; +import { Search } from "./Search"; export default function Page() { return ( <> - + ); diff --git a/src/app/trending/Trending.tsx b/src/app/trending/Trending.tsx index 36526ee..20516b3 100644 --- a/src/app/trending/Trending.tsx +++ b/src/app/trending/Trending.tsx @@ -15,6 +15,7 @@ import getRegionCodes from "@/utils/getRegionCodes"; import { RegionSwitcher } from "./RegionSwitcher"; import { defaultRegion } from "@/constants"; import { Video } from "@/components/Video"; +import { Container } from "@/components/Container"; export const Trending: Component = ({}) => { const client = useClient(); @@ -64,37 +65,35 @@ export const Trending: Component = ({}) => { return ( <> - {isLoading && !data && } - {!isLoading && ( -
-
- - -

Trending

-
- {error && ( -
-
-

- An error occurred loading the trending page -

-

{error.toString()}

- - -
-
- )} - {data && error === null && ( -
- {data.map((video) => ( -
- )} + +
+ + +

Trending

- )} + {isLoading && !data && } + {error && ( +
+
+

+ An error occurred loading the trending page +

+

{error.toString()}

+ + +
+
+ )} + {data && error === null && ( +
+ {data.map((video) => ( +
+ )} +
); }; diff --git a/src/client/adapters/index.ts b/src/client/adapters/index.ts index 33c5c5a..8388663 100644 --- a/src/client/adapters/index.ts +++ b/src/client/adapters/index.ts @@ -1,12 +1,13 @@ import { Suggestions } from "@/client/typings/search/suggestions"; import { Video } from "@/client/typings/video"; -import { SearchResults } from "../typings/search"; +import { SearchResults } from "@/client/typings/search"; +import { SearchOptions } from "@/client/typings/search/options"; export interface ConnectedAdapter { getTrending(region: string): Promise; getSearchSuggestions(query: string): Promise; - getSearch(query: string): Promise; + getSearch(query: string, options?: SearchOptions): Promise; } export default interface Adapter { diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts index 3debd00..66722d4 100644 --- a/src/client/adapters/invidious/index.ts +++ b/src/client/adapters/invidious/index.ts @@ -56,11 +56,24 @@ const getSearchSuggestions = async ( return data; }; -const getSearch = async (baseUrl: string, query: string): Promise => { +export interface SearchOptions { + page?: number; + sort_by?: "relevance" | "rating" | "upload_date" | "view_count"; + date?: "hour" | "today" | "week" | "month" | "year"; + duration?: "short" | "long" | "medium"; + type?: "video" | "playlist" | "channel" | "movie" | "show" | "all"; + region?: string; +} + +const getSearch = async ( + baseUrl: string, + query: string, + options?: SearchOptions +): Promise => { const url = new URL(apiPath("search"), baseUrl); const response = await ky.get(url, { - searchParams: { q: query } + searchParams: { ...options, q: query } }); const json = await response.json(); @@ -75,15 +88,18 @@ const adapter: Adapter = { connect(url) { return { - getTrending(region) { + async getTrending(region) { return getTrending(url, region).then(Transformer.videos); }, - getSearchSuggestions(query) { + async getSearchSuggestions(query) { return getSearchSuggestions(url, query).then(Transformer.suggestions); }, - getSearch(query) { - return getSearch(url, query).then(Transformer.search); + async getSearch(query, options) { + return getSearch(url, query, { + page: options?.page, + type: options?.type + }).then(Transformer.search); } }; } diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts index d0a00c2..c1c77cb 100644 --- a/src/client/adapters/piped/index.ts +++ b/src/client/adapters/piped/index.ts @@ -8,6 +8,7 @@ import Video, { VideoModel } from "./typings/video"; import Transformer from "./transformer"; import { Suggestions } from "@/client/typings/search/suggestions"; import Search, { SearchModel } from "./typings/search"; +import path from "path"; const getTrending = async ( apiBaseUrl: string, @@ -43,14 +44,35 @@ const getSearchSuggestions = async ( return data; }; +export type FilterType = + | "all" + | "videos" + | "channels" + | "playlists" + | "music_videos" + | "music_songs" + | "music_albums" + | "music_playlists" + | "music_artists"; + +export interface SearchOptions { + filter?: FilterType; + nextpage?: string; +} + const getSearch = async ( apiBaseUrl: string, - query: string + query: string, + options?: SearchOptions ): Promise => { - const url = new URL("search", apiBaseUrl); + let url: URL; + + if (options?.nextpage) + url = new URL(path.join("nextpage", "search"), apiBaseUrl); + else url = new URL("search", apiBaseUrl); const response = await ky.get(url, { - searchParams: { q: query, filter: "all" } + searchParams: { ...options, q: query } }); const json = await response.json(); @@ -65,15 +87,37 @@ const adapter: Adapter = { connect(url) { return { - getTrending(region) { + async getTrending(region) { return getTrending(url, region).then(Transformer.videos); }, - getSearchSuggestions(query) { + async getSearchSuggestions(query) { return getSearchSuggestions(url, query); }, - getSearch(query) { - return getSearch(url, query).then(Transformer.search); + async getSearch(query, options) { + let filter: FilterType; + + switch (options?.type) { + default: + filter = "all"; + break; + + case "channel": + filter = "channels"; + break; + + case "playlist": + filter = "playlists"; + break; + + case "video": + filter = "videos"; + break; + } + + return getSearch(url, query, { filter: filter }).then( + Transformer.search + ); } }; } diff --git a/src/client/adapters/piped/transformer.ts b/src/client/adapters/piped/transformer.ts index 8c200d3..21c6c0d 100644 --- a/src/client/adapters/piped/transformer.ts +++ b/src/client/adapters/piped/transformer.ts @@ -63,7 +63,7 @@ export default class Transformer { thumbnail: result.thumbnail, subscribers: result.subscribers, videos: result.videos, - description: result.description + description: result.description ?? "" }; return channel; diff --git a/src/client/adapters/piped/typings/search/index.ts b/src/client/adapters/piped/typings/search/index.ts index 94c9bc2..d865450 100644 --- a/src/client/adapters/piped/typings/search/index.ts +++ b/src/client/adapters/piped/typings/search/index.ts @@ -12,7 +12,7 @@ export const ChannelResultModel = z.object({ url: z.string(), name: z.string(), thumbnail: z.string().url(), - description: z.string(), + description: z.string().nullable(), subscribers: z.number(), videos: z.number(), verified: z.boolean() diff --git a/src/client/adapters/piped/typings/video.ts b/src/client/adapters/piped/typings/video.ts index 4427378..97553f0 100644 --- a/src/client/adapters/piped/typings/video.ts +++ b/src/client/adapters/piped/typings/video.ts @@ -5,7 +5,7 @@ export const VideoModel = z.object({ thumbnail: z.string().url(), // The thumbnail of the video title: z.string(), // The title of the video uploaded: z.number(), - uploadedDate: z.string(), // The date the video was uploaded + uploadedDate: z.string().nullable(), // The date the video was uploaded uploaderName: z.string(), uploaderAvatar: z.string().url(), // The avatar of the channel of the video uploaderUrl: z.string(), // The URL of the channel of the video diff --git a/src/client/index.ts b/src/client/index.ts index a0f309f..2bb6b79 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,6 +6,7 @@ import PipedAdapter from "./adapters/piped"; import Adapter, { ApiType, ConnectedAdapter } from "./adapters"; import { Suggestions } from "./typings/search/suggestions"; import { SearchResults } from "./typings/search"; +import { SearchOptions } from "./typings/search/options"; export interface RemoteApi { type: ApiType; @@ -61,9 +62,15 @@ export default class Client { return await adapter.getSearchSuggestions(query); } - public async getSearch(query: string): Promise { + public async getSearch( + query: string, + options?: SearchOptions + ): Promise { const adapter = this.getBestAdapter(); - return await adapter.getSearch(query); + return await adapter.getSearch(query, { + page: options?.page ?? 1, + type: options?.type ?? "all" + }); } } diff --git a/src/client/typings/search/options.ts b/src/client/typings/search/options.ts new file mode 100644 index 0000000..eb0f25d --- /dev/null +++ b/src/client/typings/search/options.ts @@ -0,0 +1,6 @@ +export interface SearchOptions { + page?: number; + type?: SearchType; +} + +export type SearchType = "video" | "playlist" | "channel" | "all"; diff --git a/src/components/Container.tsx b/src/components/Container.tsx new file mode 100644 index 0000000..1ec4b71 --- /dev/null +++ b/src/components/Container.tsx @@ -0,0 +1,21 @@ +import { Component } from "@/typings/component"; +import { navHeight } from "./Nav"; + +export const Container: Component<{ navbarOffset?: boolean }> = ({ + children, + navbarOffset = true +}) => { + let height; + + if (navbarOffset) height = `calc(100vh - ${navHeight}px)`; + else height = "100vh"; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index d0f9187..9663caa 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -5,7 +5,7 @@ import { CircularProgress } from "@nextui-org/progress"; export const LoadingPage: Component = () => { return ( -
+
); diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index c2f7acf..2c915a6 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -12,7 +12,9 @@ import { Button } from "@nextui-org/button"; import NextLink from "next/link"; import { usePathname } from "next/navigation"; -import { Search } from "./Search"; +// import { Search } from "./Search"; + +export const navHeight = 64; export const Nav: Component = () => { const navItems = [ diff --git a/src/components/Search.tsx b/src/components/Search.tsx index 212713b..ed7e2b2 100644 --- a/src/components/Search.tsx +++ b/src/components/Search.tsx @@ -4,17 +4,22 @@ 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 { FormEventHandler, 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 }) => { const client = useClient(); - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(initialQueryValue ?? ""); + + const router = useRouter(); - const [searchQueryDebounced] = useDebounce(searchQuery, 500); + const [searchQueryDebounced] = useDebounce(searchQuery, 250); const { isLoading, error, data } = useQuery({ queryKey: ["search", "suggestions", searchQueryDebounced], @@ -22,29 +27,50 @@ export const Search: Component<{ initialQueryValue?: string }> = ({ enabled: searchQueryDebounced.length !== 0 }); - const handleSubmit: FormEventHandler = (e) => { - console.log(searchQuery); - }; + const handleSubmit = useCallback(() => { + router.push(`/results?search_query=${searchQuery}`); + }, [searchQuery]); - const suggestions = data ?? []; + const suggestions = useMemo( + () => + data?.map((suggestion) => ({ + label: suggestion, + value: suggestion + })) ?? [], + [data] + ); return ( - - {suggestions.map((suggestion) => ( - - {suggestion} - - ))} - +
+ } + defaultItems={suggestions} + onSelectionChange={(key) => { + if (key === null) return; + + setSearchQuery(key.toString()); + handleSubmit(); + }} + errorMessage={error !== null ? error.toString() : ""} + isInvalid={error !== null} + required + type="text" + label="Search" + variant="bordered" + placeholder="Search for videos" + > + {(suggestion) => ( + + {suggestion.label} + + )} + +
); }; diff --git a/src/components/Video.tsx b/src/components/Video.tsx index b8d42b5..1d1ac40 100644 --- a/src/components/Video.tsx +++ b/src/components/Video.tsx @@ -11,9 +11,19 @@ import formatUploadedTime from "@/utils/formatUploadedTime"; import { Tooltip } from "@nextui-org/tooltip"; import { ContextMenuItem } from "@/typings/contextMenu"; -export const Video: Component<{ data: VideoProps }> = ({ data: video }) => { - const url = `/watch?v=${video.id}`; - const channelUrl = `/channel/${video.author.id}`; +import NextImage from "next/image"; +import { useMemo } from "react"; + +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 menuItems: ContextMenuItem[] = [ { title: "Go to video", key: "gotoVideo", href: url }, @@ -21,20 +31,20 @@ export const Video: Component<{ data: VideoProps }> = ({ data: video }) => { title: "Copy video id", key: "videoId", onClick: () => { - navigator.clipboard.writeText(video.id); + navigator.clipboard.writeText(data.id); }, showDivider: true }, { title: "Open thumbnail", key: "thumbnail", - href: video.thumbnail + href: data.thumbnail }, { title: "Copy thumnail url", key: "thumbnailUrl", onClick: () => { - navigator.clipboard.writeText(video.thumbnail); + navigator.clipboard.writeText(data.thumbnail); }, showDivider: true }, @@ -43,7 +53,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data: video }) => { title: "Copy channel id", key: "channelId", onClick: () => { - navigator.clipboard.writeText(video.author.id); + navigator.clipboard.writeText(data.author.id); } } ]; @@ -54,34 +64,37 @@ export const Video: Component<{ data: VideoProps }> = ({ data: video }) => { {video.title} +

- {formatDuration(video.duration)} + {formatDuration(data.duration)}

-

- {video.title} +

+ {data.title}

- {video.author.name} + {data.author.name}

- +

- {formatUploadedTime(video.uploaded)} + {formatUploadedTime(data.uploaded)}

- Views: {formatViewCount(video.views)} + Views: {formatViewCount(data.views)}