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

nextui
Guus van Meerveld 1 month 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 { SearchPage } from "./SearchPage";
import { Search } from "./Search";
export default function Page() {
return (
<>
<Suspense>
<SearchPage />
<Search />
</Suspense>
</>
);

@ -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 && <LoadingPage />}
{!isLoading && (
<div className="container mx-auto px-4 min-h-screen">
<div className="flex items-center">
<RegionSwitcher currentRegion={region} regions={validRegions} />
<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>
)}
<Container>
<div className="flex items-center">
<RegionSwitcher currentRegion={region} regions={validRegions} />
<Spacer x={4} />
<h1 className="text-xl">Trending</h1>
</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 { 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<Video[]>;
getSearchSuggestions(query: string): Promise<Suggestions>;
getSearch(query: string): Promise<SearchResults>;
getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
}
export default interface Adapter {

@ -56,11 +56,24 @@ const getSearchSuggestions = async (
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 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);
}
};
}

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

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

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

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

@ -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<SearchResults> {
public async getSearch(
query: string,
options?: SearchOptions
): Promise<SearchResults> {
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 = () => {
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..." />
</div>
);

@ -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 = [

@ -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 (
<Autocomplete
isClearable
value={searchQuery}
isLoading={isLoading}
defaultInputValue={initialQueryValue}
onSubmit={handleSubmit}
onValueChange={setSearchQuery}
label="Search"
variant="flat"
placeholder="Search for videos"
>
{suggestions.map((suggestion) => (
<AutocompleteItem key={suggestion.toLowerCase()}>
{suggestion}
</AutocompleteItem>
))}
</Autocomplete>
<form onSubmit={handleSubmit}>
<Autocomplete
isClearable
name="search-bar"
value={searchQuery}
isLoading={isLoading}
defaultInputValue={initialQueryValue}
onValueChange={setSearchQuery}
startContent={<SearchIcon className="text-xl" />}
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) => (
<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 { 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 }) => {
<Card radius="lg">
<CardBody>
<Image
alt={video.title}
className="object-cover"
height={400}
src={video.thumbnail}
width={600}
as={NextImage}
height={height}
width={width}
unoptimized
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">
{formatDuration(video.duration)}
{formatDuration(data.duration)}
</p>
</CardBody>
<Divider />
<CardFooter>
<div className="max-w-full">
<p title={video.title} className="truncate">
{video.title}
<p title={data.title} className="truncate">
{data.title}
</p>
<div className="flex flex-row gap-2 justify-start overflow-scroll">
<p className="text-small font-semibold tracking-tight text-default-400">
{video.author.name}
{data.author.name}
</p>
<Tooltip showArrow content={video.uploaded.toLocaleString()}>
<Tooltip showArrow content={data.uploaded.toLocaleString()}>
<p className="text-small tracking-tight text-default-400">
{formatUploadedTime(video.uploaded)}
{formatUploadedTime(data.uploaded)}
</p>
</Tooltip>
<p className="text-small tracking-tight text-default-400">
Views: {formatViewCount(video.views)}
Views: {formatViewCount(data.views)}
</p>
</div>
</div>

Loading…
Cancel
Save