added basic search page
continuous-integration/drone/push Build is passing Details

nextui
Guus van Meerveld 2 months ago
parent c7dd2ddd12
commit 5ac329296e

@ -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 (
<Link href={url}>
<Card>
<CardBody>
<div className="flex flex-row gap-4">
<Image
width={imageSize}
height={imageSize}
src={data.thumbnail}
alt={data.name}
as={NextImage}
unoptimized
/>
<div className="flex-1 flex flex-col">
<h1 className="text-lg">{data.name}</h1>
<div className="flex flex-row gap-4 items-center font-semibold text-default-600">
<h1>Subscribers: {formatViewCount(data.subscribers)}</h1>
<h1>Videos: {formatViewCount(data.videos)}</h1>
</div>
<p className="text-default-600">{data.description}</p>
</div>
</div>
</CardBody>
</Card>
</Link>
);
};

@ -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 (
<>
<Container>
<SearchInput initialQueryValue={query ?? undefined} />
<Spacer y={4} />
{isLoading && <LoadingPage />}
{error && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl">
An error occurred loading the search page
</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</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} />;
case "video":
return <Video key={result.id} data={result} />;
default:
break;
}
})}
</div>
</Container>
</>
);
};

@ -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 (
<>
<div className="container mx-auto py-4">
<Search initialQueryValue={query || undefined} />
</div>
</>
);
};

@ -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 (
<Link href={url}>
<Card>
<CardBody>
<div className="flex flex-row gap-4">
<Image
width={width}
height={height}
src={data.thumbnail}
alt={data.title}
as={NextImage}
unoptimized
className="aspect-video"
/>
<div className="flex flex-col gap-2">
<h1 className="text-xl">{data.title}</h1>
</div>
</div>
</CardBody>
</Card>
</Link>
);
};

@ -1,11 +1,11 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { SearchPage } from "./SearchPage"; import { Search } from "./Search";
export default function Page() { export default function Page() {
return ( return (
<> <>
<Suspense> <Suspense>
<SearchPage /> <Search />
</Suspense> </Suspense>
</> </>
); );

@ -15,6 +15,7 @@ import getRegionCodes from "@/utils/getRegionCodes";
import { RegionSwitcher } from "./RegionSwitcher"; import { RegionSwitcher } from "./RegionSwitcher";
import { defaultRegion } from "@/constants"; import { defaultRegion } from "@/constants";
import { Video } from "@/components/Video"; import { Video } from "@/components/Video";
import { Container } from "@/components/Container";
export const Trending: Component = ({}) => { export const Trending: Component = ({}) => {
const client = useClient(); const client = useClient();
@ -64,37 +65,35 @@ export const Trending: Component = ({}) => {
return ( return (
<> <>
{isLoading && !data && <LoadingPage />} <Container>
{!isLoading && ( <div className="flex items-center">
<div className="container mx-auto px-4 min-h-screen"> <RegionSwitcher currentRegion={region} regions={validRegions} />
<div className="flex items-center"> <Spacer x={4} />
<RegionSwitcher currentRegion={region} regions={validRegions} /> <h1 className="text-xl">Trending</h1>
<Spacer x={4} />
<h1 className="text-xl">Trending</h1>
</div>
{error && (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<h1 className="text-xl">
An error occurred loading the trending page
</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
)}
{data && error === null && (
<div className="grid gap-4 py-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{data.map((video) => (
<Video key={video.id} data={video} />
))}
</div>
)}
</div> </div>
)} {isLoading && !data && <LoadingPage />}
{error && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl">
An error occurred loading the trending page
</h1>
<h2 className="text-lg">{error.toString()}</h2>
<Spacer y={2} />
<Button color="primary" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
)}
{data && error === null && (
<div className="grid gap-4 py-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{data.map((video) => (
<Video key={video.id} data={video} />
))}
</div>
)}
</Container>
</> </>
); );
}; };

@ -1,12 +1,13 @@
import { Suggestions } from "@/client/typings/search/suggestions"; import { Suggestions } from "@/client/typings/search/suggestions";
import { Video } from "@/client/typings/video"; 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 { export interface ConnectedAdapter {
getTrending(region: string): Promise<Video[]>; getTrending(region: string): Promise<Video[]>;
getSearchSuggestions(query: string): Promise<Suggestions>; getSearchSuggestions(query: string): Promise<Suggestions>;
getSearch(query: string): Promise<SearchResults>; getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
} }
export default interface Adapter { export default interface Adapter {

@ -56,11 +56,24 @@ const getSearchSuggestions = async (
return data; return data;
}; };
const getSearch = async (baseUrl: string, query: string): Promise<Search> => { 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<Search> => {
const url = new URL(apiPath("search"), baseUrl); const url = new URL(apiPath("search"), baseUrl);
const response = await ky.get(url, { const response = await ky.get(url, {
searchParams: { q: query } searchParams: { ...options, q: query }
}); });
const json = await response.json(); const json = await response.json();
@ -75,15 +88,18 @@ const adapter: Adapter = {
connect(url) { connect(url) {
return { return {
getTrending(region) { async getTrending(region) {
return getTrending(url, region).then(Transformer.videos); return getTrending(url, region).then(Transformer.videos);
}, },
getSearchSuggestions(query) { async getSearchSuggestions(query) {
return getSearchSuggestions(url, query).then(Transformer.suggestions); return getSearchSuggestions(url, query).then(Transformer.suggestions);
}, },
getSearch(query) { async getSearch(query, options) {
return getSearch(url, query).then(Transformer.search); return getSearch(url, query, {
page: options?.page,
type: options?.type
}).then(Transformer.search);
} }
}; };
} }

@ -8,6 +8,7 @@ import Video, { VideoModel } from "./typings/video";
import Transformer from "./transformer"; import Transformer from "./transformer";
import { Suggestions } from "@/client/typings/search/suggestions"; import { Suggestions } from "@/client/typings/search/suggestions";
import Search, { SearchModel } from "./typings/search"; import Search, { SearchModel } from "./typings/search";
import path from "path";
const getTrending = async ( const getTrending = async (
apiBaseUrl: string, apiBaseUrl: string,
@ -43,14 +44,35 @@ const getSearchSuggestions = async (
return data; 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 ( const getSearch = async (
apiBaseUrl: string, apiBaseUrl: string,
query: string query: string,
options?: SearchOptions
): Promise<Search> => { ): Promise<Search> => {
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, { const response = await ky.get(url, {
searchParams: { q: query, filter: "all" } searchParams: { ...options, q: query }
}); });
const json = await response.json(); const json = await response.json();
@ -65,15 +87,37 @@ const adapter: Adapter = {
connect(url) { connect(url) {
return { return {
getTrending(region) { async getTrending(region) {
return getTrending(url, region).then(Transformer.videos); return getTrending(url, region).then(Transformer.videos);
}, },
getSearchSuggestions(query) { async getSearchSuggestions(query) {
return getSearchSuggestions(url, query); return getSearchSuggestions(url, query);
}, },
getSearch(query) { async getSearch(query, options) {
return getSearch(url, query).then(Transformer.search); 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
);
} }
}; };
} }

@ -63,7 +63,7 @@ export default class Transformer {
thumbnail: result.thumbnail, thumbnail: result.thumbnail,
subscribers: result.subscribers, subscribers: result.subscribers,
videos: result.videos, videos: result.videos,
description: result.description description: result.description ?? ""
}; };
return channel; return channel;

@ -12,7 +12,7 @@ export const ChannelResultModel = z.object({
url: z.string(), url: z.string(),
name: z.string(), name: z.string(),
thumbnail: z.string().url(), thumbnail: z.string().url(),
description: z.string(), description: z.string().nullable(),
subscribers: z.number(), subscribers: z.number(),
videos: z.number(), videos: z.number(),
verified: z.boolean() verified: z.boolean()

@ -5,7 +5,7 @@ export const VideoModel = z.object({
thumbnail: z.string().url(), // The thumbnail of the video thumbnail: z.string().url(), // The thumbnail of the video
title: z.string(), // The title of the video title: z.string(), // The title of the video
uploaded: z.number(), 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(), uploaderName: z.string(),
uploaderAvatar: z.string().url(), // The avatar of the channel of the video uploaderAvatar: z.string().url(), // The avatar of the channel of the video
uploaderUrl: z.string(), // The URL of the channel of the video uploaderUrl: z.string(), // The URL of the channel of the video

@ -6,6 +6,7 @@ import PipedAdapter from "./adapters/piped";
import Adapter, { ApiType, ConnectedAdapter } from "./adapters"; import Adapter, { ApiType, ConnectedAdapter } from "./adapters";
import { Suggestions } from "./typings/search/suggestions"; import { Suggestions } from "./typings/search/suggestions";
import { SearchResults } from "./typings/search"; import { SearchResults } from "./typings/search";
import { SearchOptions } from "./typings/search/options";
export interface RemoteApi { export interface RemoteApi {
type: ApiType; type: ApiType;
@ -61,9 +62,15 @@ export default class Client {
return await adapter.getSearchSuggestions(query); return await adapter.getSearchSuggestions(query);
} }
public async getSearch(query: string): Promise<SearchResults> { public async getSearch(
query: string,
options?: SearchOptions
): Promise<SearchResults> {
const adapter = this.getBestAdapter(); const adapter = this.getBestAdapter();
return await adapter.getSearch(query); return await adapter.getSearch(query, {
page: options?.page ?? 1,
type: options?.type ?? "all"
});
} }
} }

@ -0,0 +1,6 @@
export interface SearchOptions {
page?: number;
type?: SearchType;
}
export type SearchType = "video" | "playlist" | "channel" | "all";

@ -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 (
<div
style={{ minHeight: height }}
className="container mx-auto py-4 px-2 flex flex-col"
>
{children}
</div>
);
};

@ -5,7 +5,7 @@ import { CircularProgress } from "@nextui-org/progress";
export const LoadingPage: Component = () => { export const LoadingPage: Component = () => {
return ( return (
<div className="h-screen container mx-auto flex items-center justify-center"> <div className="flex flex-1 justify-center items-center">
<CircularProgress aria-label="Loading page..." /> <CircularProgress aria-label="Loading page..." />
</div> </div>
); );

@ -12,7 +12,9 @@ import { Button } from "@nextui-org/button";
import NextLink from "next/link"; import NextLink from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Search } from "./Search"; // import { Search } from "./Search";
export const navHeight = 64;
export const Nav: Component = () => { export const Nav: Component = () => {
const navItems = [ const navItems = [

@ -4,17 +4,22 @@ 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, useState } from "react"; import { FormEventHandler, useCallback, useMemo, useState } from "react";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
import { FiSearch as SearchIcon } from "react-icons/fi";
import { useRouter } from "next/navigation";
export const Search: Component<{ initialQueryValue?: string }> = ({ export const Search: Component<{ initialQueryValue?: string }> = ({
initialQueryValue initialQueryValue
}) => { }) => {
const client = useClient(); 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({ const { isLoading, error, data } = useQuery({
queryKey: ["search", "suggestions", searchQueryDebounced], queryKey: ["search", "suggestions", searchQueryDebounced],
@ -22,29 +27,50 @@ export const Search: Component<{ initialQueryValue?: string }> = ({
enabled: searchQueryDebounced.length !== 0 enabled: searchQueryDebounced.length !== 0
}); });
const handleSubmit: FormEventHandler = (e) => { const handleSubmit = useCallback(() => {
console.log(searchQuery); router.push(`/results?search_query=${searchQuery}`);
}; }, [searchQuery]);
const suggestions = data ?? []; const suggestions = useMemo(
() =>
data?.map((suggestion) => ({
label: suggestion,
value: suggestion
})) ?? [],
[data]
);
return ( return (
<Autocomplete <form onSubmit={handleSubmit}>
isClearable <Autocomplete
value={searchQuery} isClearable
isLoading={isLoading} name="search-bar"
defaultInputValue={initialQueryValue} value={searchQuery}
onSubmit={handleSubmit} isLoading={isLoading}
onValueChange={setSearchQuery} defaultInputValue={initialQueryValue}
label="Search" onValueChange={setSearchQuery}
variant="flat" startContent={<SearchIcon className="text-xl" />}
placeholder="Search for videos" defaultItems={suggestions}
> onSelectionChange={(key) => {
{suggestions.map((suggestion) => ( if (key === null) return;
<AutocompleteItem key={suggestion.toLowerCase()}>
{suggestion} setSearchQuery(key.toString());
</AutocompleteItem> handleSubmit();
))} }}
</Autocomplete> errorMessage={error !== null ? error.toString() : ""}
isInvalid={error !== null}
required
type="text"
label="Search"
variant="bordered"
placeholder="Search for videos"
>
{(suggestion) => (
<AutocompleteItem key={suggestion.value}>
{suggestion.label}
</AutocompleteItem>
)}
</Autocomplete>
</form>
); );
}; };

@ -11,9 +11,19 @@ import formatUploadedTime from "@/utils/formatUploadedTime";
import { Tooltip } from "@nextui-org/tooltip"; import { Tooltip } from "@nextui-org/tooltip";
import { ContextMenuItem } from "@/typings/contextMenu"; import { ContextMenuItem } from "@/typings/contextMenu";
export const Video: Component<{ data: VideoProps }> = ({ data: video }) => { import NextImage from "next/image";
const url = `/watch?v=${video.id}`; import { useMemo } from "react";
const channelUrl = `/channel/${video.author.id}`;
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[] = [ const menuItems: ContextMenuItem[] = [
{ title: "Go to video", key: "gotoVideo", href: url }, { title: "Go to video", key: "gotoVideo", href: url },
@ -21,20 +31,20 @@ export const Video: Component<{ data: VideoProps }> = ({ data: video }) => {
title: "Copy video id", title: "Copy video id",
key: "videoId", key: "videoId",
onClick: () => { onClick: () => {
navigator.clipboard.writeText(video.id); navigator.clipboard.writeText(data.id);
}, },
showDivider: true showDivider: true
}, },
{ {
title: "Open thumbnail", title: "Open thumbnail",
key: "thumbnail", key: "thumbnail",
href: video.thumbnail href: data.thumbnail
}, },
{ {
title: "Copy thumnail url", title: "Copy thumnail url",
key: "thumbnailUrl", key: "thumbnailUrl",
onClick: () => { onClick: () => {
navigator.clipboard.writeText(video.thumbnail); navigator.clipboard.writeText(data.thumbnail);
}, },
showDivider: true showDivider: true
}, },
@ -43,7 +53,7 @@ export const Video: Component<{ data: VideoProps }> = ({ data: video }) => {
title: "Copy channel id", title: "Copy channel id",
key: "channelId", key: "channelId",
onClick: () => { 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 }) => {
<Card radius="lg"> <Card radius="lg">
<CardBody> <CardBody>
<Image <Image
alt={video.title} as={NextImage}
className="object-cover" height={height}
height={400} width={width}
src={video.thumbnail} unoptimized
width={600} alt={data.title}
className="object-contain aspect-video"
src={data.thumbnail}
/> />
<p className="text-small rounded-md z-10 absolute bottom-5 right-5 bg-content2 p-1"> <p className="text-small rounded-md z-10 absolute bottom-5 right-5 bg-content2 p-1">
{formatDuration(video.duration)} {formatDuration(data.duration)}
</p> </p>
</CardBody> </CardBody>
<Divider /> <Divider />
<CardFooter> <CardFooter>
<div className="max-w-full"> <div className="max-w-full">
<p title={video.title} className="truncate"> <p title={data.title} className="truncate">
{video.title} {data.title}
</p> </p>
<div className="flex flex-row gap-2 justify-start overflow-scroll"> <div className="flex flex-row gap-2 justify-start overflow-scroll">
<p className="text-small font-semibold tracking-tight text-default-400"> <p className="text-small font-semibold tracking-tight text-default-400">
{video.author.name} {data.author.name}
</p> </p>
<Tooltip showArrow content={video.uploaded.toLocaleString()}> <Tooltip showArrow content={data.uploaded.toLocaleString()}>
<p className="text-small tracking-tight text-default-400"> <p className="text-small tracking-tight text-default-400">
{formatUploadedTime(video.uploaded)} {formatUploadedTime(data.uploaded)}
</p> </p>
</Tooltip> </Tooltip>
<p className="text-small tracking-tight text-default-400"> <p className="text-small tracking-tight text-default-400">
Views: {formatViewCount(video.views)} Views: {formatViewCount(data.views)}
</p> </p>
</div> </div>
</div> </div>

Loading…
Cancel
Save