mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 18:21:53 +01:00
new search view
This commit is contained in:
parent
4dd0f22a04
commit
42402eb5c7
@ -17,6 +17,7 @@
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^12.1.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-stickynode": "^4.1.0",
|
||||
"srt-webvtt": "^2.0.0",
|
||||
"unpacker": "^1.0.1"
|
||||
},
|
||||
@ -47,6 +48,7 @@
|
||||
"@types/react-dom": "^17.0.11",
|
||||
"@types/react-router": "^5.1.18",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-stickynode": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"@vitejs/plugin-react-swc": "^3.0.0",
|
||||
@ -59,12 +61,15 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"i": "^0.3.7",
|
||||
"npm": "^9.2.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.2.4",
|
||||
"typescript": "^4.6.4",
|
||||
"vite": "^4.0.1"
|
||||
"vite": "^4.0.1",
|
||||
"vite-plugin-package-version": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { MWMediaType, MWQuery } from "@/providers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DropdownButton } from "./buttons/DropdownButton";
|
||||
import { Icons } from "./Icon";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||
|
||||
export interface SearchBarProps {
|
||||
@ -37,15 +37,20 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 rounded-[28px] bg-denim-300 px-4 py-4 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-300 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:items-center">
|
||||
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
|
||||
<Icon icon={Icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInputControl
|
||||
onUnFocus={props.onUnFocus}
|
||||
onChange={(val) => setSearch(val)}
|
||||
value={props.value.searchQuery}
|
||||
className="w-full flex-1 bg-transparent text-white placeholder-denim-700 focus:outline-none"
|
||||
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-4 pt-0 sm:py-2 sm:px-2">
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
@ -55,12 +60,12 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
options={[
|
||||
{
|
||||
id: MWMediaType.MOVIE,
|
||||
name: t('searchBar.movie'),
|
||||
name: t("searchBar.movie"),
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: t('searchBar.series'),
|
||||
name: t("searchBar.series"),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
// {
|
||||
@ -71,8 +76,9 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || t('searchBar.search')}
|
||||
{props.buttonText || t("searchBar.search")}
|
||||
</DropdownButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -6,7 +6,11 @@ import React, {
|
||||
} from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import { Backdrop, useBackdrop } from "@/components/layout/Backdrop";
|
||||
import {
|
||||
Backdrop,
|
||||
BackdropContainer,
|
||||
useBackdrop,
|
||||
} from "@/components/layout/Backdrop";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface OptionItem {
|
||||
@ -56,7 +60,7 @@ export const DropdownButton = React.forwardRef<
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let id: NodeJS.Timeout;
|
||||
let id: ReturnType<typeof setTimeout>;
|
||||
|
||||
if (props.open) {
|
||||
setDelayedSelectedId(props.selectedItem);
|
||||
@ -92,16 +96,22 @@ export const DropdownButton = React.forwardRef<
|
||||
ref={ref}
|
||||
className="relative w-full sm:w-auto"
|
||||
{...highlightedProps}
|
||||
>
|
||||
<BackdropContainer
|
||||
onClick={() => props.setOpen(false)}
|
||||
{...backdropProps}
|
||||
>
|
||||
<ButtonControl
|
||||
{...props}
|
||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-200 px-4 py-2 text-white hover:bg-bink-300"
|
||||
className="sm:justify-left relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] bg-bink-400 px-4 py-2 text-white hover:bg-bink-300"
|
||||
>
|
||||
<Icon icon={selectedItem.icon} />
|
||||
<span className="flex-1">{selectedItem.name}</span>
|
||||
<Icon
|
||||
icon={Icons.CHEVRON_DOWN}
|
||||
className={`transition-transform ${props.open ? "rotate-180" : ""}`}
|
||||
className={`transition-transform ${
|
||||
props.open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</ButtonControl>
|
||||
<div
|
||||
@ -122,8 +132,8 @@ export const DropdownButton = React.forwardRef<
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BackdropContainer>
|
||||
</div>
|
||||
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React, { createRef, useEffect, useState } from "react";
|
||||
import { useFade } from "@/hooks/useFade";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface BackdropProps {
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
@ -58,7 +59,7 @@ export function Backdrop(props: BackdropProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
className={`fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
!isVisible ? "opacity-0" : ""
|
||||
}`}
|
||||
{...fadeProps}
|
||||
@ -66,3 +67,47 @@ export function Backdrop(props: BackdropProps) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackdropContainer(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
} & BackdropProps
|
||||
) {
|
||||
const root = createRef<HTMLDivElement>();
|
||||
const copy = createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
let frame = -1;
|
||||
function poll() {
|
||||
if (root.current && copy.current) {
|
||||
const rect = root.current.getBoundingClientRect();
|
||||
copy.current.style.top = `${rect.top}px`;
|
||||
copy.current.style.left = `${rect.left}px`;
|
||||
copy.current.style.width = `${rect.width}px`;
|
||||
copy.current.style.height = `${rect.height}px`;
|
||||
}
|
||||
frame = window.requestAnimationFrame(poll);
|
||||
}
|
||||
poll();
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
// we dont want this to run only on mount, dont care about ref updates
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [root, copy]);
|
||||
|
||||
return (
|
||||
<div ref={root}>
|
||||
{createPortal(
|
||||
<div className="absolute top-0 left-0 z-[999]">
|
||||
<Backdrop active={props.active} {...props} />
|
||||
<div ref={copy} className="absolute">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<div className="invisible">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Link } from "@/components/text/Link";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
|
@ -2,17 +2,25 @@ import { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
children?: ReactNode;
|
||||
bg?: boolean;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
return (
|
||||
<div className="absolute left-0 right-0 top-0 flex min-h-[88px] items-center justify-between py-5 px-7">
|
||||
<div className="flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="fixed left-0 right-0 top-0 z-10 flex min-h-[88px] items-center justify-between py-5 px-7">
|
||||
<div
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
|
||||
>
|
||||
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||
</div>
|
||||
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill clickable />
|
||||
@ -23,7 +31,7 @@ export function Navigation(props: NavigationProps) {
|
||||
<div
|
||||
className={`${
|
||||
props.children ? "hidden sm:flex" : "flex"
|
||||
} flex-row gap-4`}
|
||||
} relative flex-row gap-4`}
|
||||
>
|
||||
<a
|
||||
href={conf().DISCORD_LINK}
|
||||
|
@ -1,13 +1,10 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { ArrowLink } from "@/components/text/ArrowLink";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
icon?: Icons;
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
linkText?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@ -23,13 +20,6 @@ export function SectionHeading(props: SectionHeadingProps) {
|
||||
) : null}
|
||||
{props.title}
|
||||
</p>
|
||||
{props.linkText ? (
|
||||
<ArrowLink
|
||||
linkText={props.linkText}
|
||||
direction="left"
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
|
@ -8,7 +8,9 @@ interface ThinContainerProps {
|
||||
export function ThinContainer(props: ThinContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
|
||||
className={`mx-auto w-[600px] max-w-full px-2 sm:px-0 ${
|
||||
props.classNames || ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
@ -3,5 +3,9 @@ export interface TitleProps {
|
||||
}
|
||||
|
||||
export function Title(props: TitleProps) {
|
||||
return <h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white">{props.children}</h1>;
|
||||
return (
|
||||
<h1 className="mx-auto max-w-xs text-2xl font-bold text-white sm:text-3xl md:text-4xl">
|
||||
{props.children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
@ -1,3 +0,0 @@
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
|
||||
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web";
|
||||
export const APP_VERSION = "2.1.0";
|
@ -1,11 +1,12 @@
|
||||
import React, { Suspense } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import { ErrorBoundary } from "@/components/layout/ErrorBoundary";
|
||||
import App from "./App";
|
||||
import "./i18n";
|
||||
import { conf } from "./config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
import App from "@/setup/App";
|
||||
import "@/setup/i18n";
|
||||
import "@/setup/index.css";
|
||||
|
||||
// initialize
|
||||
const key =
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export const flixhqProvider: MWMediaProvider = {
|
||||
id: "flixhq",
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
const format = {
|
||||
stringify: (cipher: any) => {
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
MWProviderMediaResult,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export const gomostreamScraper: MWMediaProvider = {
|
||||
id: "gomostream",
|
||||
|
@ -4,7 +4,7 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
import toWebVTT from "srt-webvtt";
|
||||
import CryptoJS from "crypto-js";
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaType,
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
} from "@/providers/list/theflix/search";
|
||||
|
||||
import { getDataFromPortableSearch } from "@/providers/list/theflix/portableToMedia";
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export const theFlixScraper: MWMediaProvider = {
|
||||
id: "theflix",
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
import { MWMediaType, MWPortableMedia } from "@/providers/types";
|
||||
|
||||
const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
import { MWMediaType, MWProviderMediaResult, MWQuery } from "@/providers";
|
||||
|
||||
const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) =>
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
MWMediaCaption,
|
||||
} from "@/providers/types";
|
||||
|
||||
import { conf } from "@/config";
|
||||
import { conf } from "@/setup/config";
|
||||
|
||||
export const xemovieScraper: MWMediaProvider = {
|
||||
id: "xemovie",
|
||||
|
@ -2,10 +2,10 @@ import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { MWMediaType } from "@/providers";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
import { WatchedContextProvider } from "@/state/watched";
|
||||
|
||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||
import "./index.css";
|
||||
import { MediaView } from "./views/MediaView";
|
||||
import { SearchView } from "./views/SearchView";
|
||||
import { MediaView } from "@/views/MediaView";
|
||||
import { SearchView } from "@/views/search/SearchView";
|
||||
|
||||
function App() {
|
||||
return (
|
@ -1,4 +1,4 @@
|
||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "@/constants";
|
||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants";
|
||||
|
||||
interface Config {
|
||||
APP_VERSION: string;
|
@ -4,13 +4,11 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen;
|
||||
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen overflow-x-hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
padding: 0.05px;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
export class SimpleCache<Key, Value> {
|
||||
protected readonly INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
protected _interval: NodeJS.Timer | null = null;
|
||||
protected _interval: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
protected _compare: ((a: Key, b: Key) => boolean) | null = null;
|
||||
|
||||
@ -25,8 +25,7 @@ export class SimpleCache<Key, Value> {
|
||||
** destroy cache instance, its not safe to use the instance after calling this
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this._interval)
|
||||
clearInterval(this._interval);
|
||||
if (this._interval) clearInterval(this._interval);
|
||||
this.clear();
|
||||
}
|
||||
|
||||
@ -49,9 +48,10 @@ export class SimpleCache<Key, Value> {
|
||||
*/
|
||||
public get(key: Key): Value | undefined {
|
||||
if (!this._compare) throw new Error("Compare function not set");
|
||||
const foundValue = this._storage.find(item => this._compare && this._compare(item.key, key));
|
||||
if (!foundValue)
|
||||
return undefined;
|
||||
const foundValue = this._storage.find(
|
||||
(item) => this._compare && this._compare(item.key, key)
|
||||
);
|
||||
if (!foundValue) return undefined;
|
||||
return foundValue.value;
|
||||
}
|
||||
|
||||
@ -60,8 +60,10 @@ export class SimpleCache<Key, Value> {
|
||||
*/
|
||||
public set(key: Key, value: Value, expirySeconds: number): void {
|
||||
if (!this._compare) throw new Error("Compare function not set");
|
||||
const foundValue = this._storage.find(item => this._compare && this._compare(item.key, key));
|
||||
const expiry = new Date((new Date().getTime()) + (expirySeconds * 1000));
|
||||
const foundValue = this._storage.find(
|
||||
(item) => this._compare && this._compare(item.key, key)
|
||||
);
|
||||
const expiry = new Date(new Date().getTime() + expirySeconds * 1000);
|
||||
|
||||
// overwrite old value
|
||||
if (foundValue) {
|
||||
@ -76,7 +78,7 @@ export class SimpleCache<Key, Value> {
|
||||
key,
|
||||
value,
|
||||
expiry,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -120,7 +120,7 @@ function LoadingMediaFooter(props: { error?: boolean }) {
|
||||
{props.error ? (
|
||||
<div className="flex items-center space-x-3">
|
||||
<IconPatch icon={Icons.WARNING} className="text-red-400" />
|
||||
<p>{t('media.invalidUrl')}</p>
|
||||
<p>{t("media.invalidUrl")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<LoadingSeasons />
|
||||
@ -200,7 +200,7 @@ export function MediaView() {
|
||||
: reactHistory.push("/")
|
||||
}
|
||||
direction="left"
|
||||
linkText={t('media.arrowText')}
|
||||
linkText={t("media.arrowText")}
|
||||
/>
|
||||
</Navigation>
|
||||
<NotFoundChecks portable={mediaPortable}>
|
||||
|
@ -1,224 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { SearchBarInput } from "@/components/SearchBar";
|
||||
import { MWMassProviderOutput, MWQuery, SearchProviders } from "@/providers";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { Tagline } from "@/components/text/Tagline";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { useWatchedContext } from "@/state/watched/context";
|
||||
import {
|
||||
getIfBookmarkedFromPortable,
|
||||
useBookmarkContext,
|
||||
} from "@/state/bookmark/context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SearchLoading() {
|
||||
const { t } = useTranslation();
|
||||
return <Loading className="my-24" text={t('search.loading') || "Fetching your favourite shows..."} />;
|
||||
}
|
||||
|
||||
function SearchSuffix(props: {
|
||||
fails: number;
|
||||
total: number;
|
||||
resultsSize: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allFailed: boolean = props.fails === props.total;
|
||||
const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH;
|
||||
|
||||
return (
|
||||
<div className="my-24 flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<IconPatch
|
||||
icon={icon}
|
||||
className={`text-xl ${allFailed ? "text-red-400" : "text-bink-600"}`}
|
||||
/>
|
||||
|
||||
{/* standard suffix */}
|
||||
{!allFailed ? (
|
||||
<div>
|
||||
{props.fails > 0 ? (
|
||||
<p className="text-red-400">
|
||||
{t('search.providersFailed', { fails: props.fails, total: props.total })}
|
||||
</p>
|
||||
) : null}
|
||||
{props.resultsSize > 0 ? (
|
||||
<p>{t('search.allResults')}</p>
|
||||
) : (
|
||||
<p>{t('search.noResults')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Error result */}
|
||||
{allFailed ? (
|
||||
<div>
|
||||
<p>{t('search.allFailed')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultsView({
|
||||
searchQuery,
|
||||
clear,
|
||||
}: {
|
||||
searchQuery: MWQuery;
|
||||
clear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [results, setResults] = useState<MWMassProviderOutput | undefined>();
|
||||
const [runSearchQuery, loading, error, success] = useLoading(
|
||||
(query: MWQuery) => SearchProviders(query)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function runSearch(query: MWQuery) {
|
||||
const searchResults = await runSearchQuery(query);
|
||||
if (!searchResults) return;
|
||||
setResults(searchResults);
|
||||
}
|
||||
|
||||
if (searchQuery.searchQuery !== "") runSearch(searchQuery);
|
||||
}, [searchQuery, runSearchQuery]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* results */}
|
||||
{success && results?.results.length ? (
|
||||
<SectionHeading
|
||||
title={t('search.headingTitle') || "Search results"}
|
||||
icon={Icons.SEARCH}
|
||||
linkText={t('search.headingLink') || "Back to home"}
|
||||
onClick={() => clear()}
|
||||
>
|
||||
{results.results.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
/>
|
||||
))}
|
||||
</SectionHeading>
|
||||
) : null}
|
||||
|
||||
{/* search suffix */}
|
||||
{success && results ? (
|
||||
<SearchSuffix
|
||||
resultsSize={results.results.length}
|
||||
fails={results.stats.failed}
|
||||
total={results.stats.total}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* error */}
|
||||
{error ? <SearchSuffix resultsSize={0} fails={1} total={1} /> : null}
|
||||
|
||||
{/* Loading icon */}
|
||||
{loading ? <SearchLoading /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtraItems() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { getFilteredBookmarks } = useBookmarkContext();
|
||||
const { getFilteredWatched } = useWatchedContext();
|
||||
|
||||
const bookmarks = getFilteredBookmarks();
|
||||
|
||||
const watchedItems = getFilteredWatched().filter(
|
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v)
|
||||
);
|
||||
|
||||
if (watchedItems.length === 0 && bookmarks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-16 mt-32">
|
||||
{bookmarks.length > 0 ? (
|
||||
<SectionHeading title={t('search.bookmarks') || "Bookmarks"} icon={Icons.BOOKMARK}>
|
||||
{bookmarks.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
/>
|
||||
))}
|
||||
</SectionHeading>
|
||||
) : null}
|
||||
{watchedItems.length > 0 ? (
|
||||
<SectionHeading title={t('search.continueWatching') || "Continue Watching"} icon={Icons.CLOCK}>
|
||||
{watchedItems.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
series
|
||||
/>
|
||||
))}
|
||||
</SectionHeading>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchView() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searching, setSearching] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery();
|
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 2000);
|
||||
useEffect(() => {
|
||||
setSearching(search.searchQuery !== "");
|
||||
setLoading(search.searchQuery !== "");
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const resultView = useMemo(() => {
|
||||
if (loading) return <SearchLoading />;
|
||||
if (searching)
|
||||
return (
|
||||
<SearchResultsView
|
||||
searchQuery={debouncedSearch}
|
||||
clear={() => setSearch({ searchQuery: "" }, true)}
|
||||
/>
|
||||
);
|
||||
return <ExtraItems />;
|
||||
}, [loading, searching, debouncedSearch, setSearch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<ThinContainer>
|
||||
{/* input section */}
|
||||
<div className="mt-44 space-y-16 text-center">
|
||||
<div className="space-y-4">
|
||||
<Tagline>{t('search.tagline')}</Tagline>
|
||||
<Title>{t('search.title')}</Title>
|
||||
</div>
|
||||
<SearchBarInput
|
||||
onChange={setSearch}
|
||||
value={search}
|
||||
onUnFocus={setSearchUnFocus}
|
||||
placeholder={t('search.placeholder') || "What do you want to watch?"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* results view */}
|
||||
{resultView}
|
||||
</ThinContainer>
|
||||
</>
|
||||
);
|
||||
}
|
@ -26,11 +26,9 @@ export function NotFoundMedia() {
|
||||
icon={Icons.EYE_SLASH}
|
||||
className="mb-6 text-xl text-bink-600"
|
||||
/>
|
||||
<Title>{t('notFound.media.title')}</Title>
|
||||
<p className="mt-5 mb-12 max-w-sm">
|
||||
{t('notFound.media.description')}
|
||||
</p>
|
||||
<ArrowLink to="/" linkText={t('notFound.backArrow')} />
|
||||
<Title>{t("notFound.media.title")}</Title>
|
||||
<p className="mt-5 mb-12 max-w-sm">{t("notFound.media.description")}</p>
|
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -44,11 +42,11 @@ export function NotFoundProvider() {
|
||||
icon={Icons.EYE_SLASH}
|
||||
className="mb-6 text-xl text-bink-600"
|
||||
/>
|
||||
<Title>{t('notFound.provider.title')}</Title>
|
||||
<Title>{t("notFound.provider.title")}</Title>
|
||||
<p className="mt-5 mb-12 max-w-sm">
|
||||
{t('notFound.provider.description')}
|
||||
{t("notFound.provider.description")}
|
||||
</p>
|
||||
<ArrowLink to="/" linkText={t('notFound.backArrow')} />
|
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -62,11 +60,9 @@ export function NotFoundPage() {
|
||||
icon={Icons.EYE_SLASH}
|
||||
className="mb-6 text-xl text-bink-600"
|
||||
/>
|
||||
<Title>{t('notFound.page.title')}</Title>
|
||||
<p className="mt-5 mb-12 max-w-sm">
|
||||
{t('notFound.page.description')}
|
||||
</p>
|
||||
<ArrowLink to="/" linkText={t('notFound.backArrow')} />
|
||||
<Title>{t("notFound.page.title")}</Title>
|
||||
<p className="mt-5 mb-12 max-w-sm">{t("notFound.page.description")}</p>
|
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} />
|
||||
</NotFoundWrapper>
|
||||
);
|
||||
}
|
||||
|
65
src/views/search/HomeView.tsx
Normal file
65
src/views/search/HomeView.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import {
|
||||
getIfBookmarkedFromPortable,
|
||||
useBookmarkContext,
|
||||
} from "@/state/bookmark";
|
||||
import { useWatchedContext } from "@/state/watched";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function Bookmarks() {
|
||||
const { t } = useTranslation();
|
||||
const { getFilteredBookmarks } = useBookmarkContext();
|
||||
const bookmarks = getFilteredBookmarks();
|
||||
|
||||
if (bookmarks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionHeading
|
||||
title={t("search.bookmarks") || "Bookmarks"}
|
||||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
{bookmarks.map((v) => (
|
||||
<WatchedMediaCard key={[v.mediaId, v.providerId].join("|")} media={v} />
|
||||
))}
|
||||
</SectionHeading>
|
||||
);
|
||||
}
|
||||
|
||||
function Watched() {
|
||||
const { t } = useTranslation();
|
||||
const { getFilteredBookmarks } = useBookmarkContext();
|
||||
const { getFilteredWatched } = useWatchedContext();
|
||||
|
||||
const bookmarks = getFilteredBookmarks();
|
||||
const watchedItems = getFilteredWatched().filter(
|
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v)
|
||||
);
|
||||
|
||||
if (watchedItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SectionHeading
|
||||
title={t("search.continueWatching") || "Continue Watching"}
|
||||
icon={Icons.CLOCK}
|
||||
>
|
||||
{watchedItems.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
series
|
||||
/>
|
||||
))}
|
||||
</SectionHeading>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomeView() {
|
||||
return (
|
||||
<div className="mb-16 mt-32">
|
||||
<Bookmarks />
|
||||
<Watched />
|
||||
</div>
|
||||
);
|
||||
}
|
12
src/views/search/SearchLoadingView.tsx
Normal file
12
src/views/search/SearchLoadingView.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function SearchLoadingView() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Loading
|
||||
className="my-24"
|
||||
text={t("search.loading") || "Fetching your favourite shows..."}
|
||||
/>
|
||||
);
|
||||
}
|
32
src/views/search/SearchResultsPartial.tsx
Normal file
32
src/views/search/SearchResultsPartial.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { MWQuery } from "@/providers";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { HomeView } from "./HomeView";
|
||||
import { SearchLoadingView } from "./SearchLoadingView";
|
||||
import { SearchResultsView } from "./SearchResultsView";
|
||||
|
||||
interface SearchResultsPartialProps {
|
||||
search: MWQuery;
|
||||
}
|
||||
|
||||
export function SearchResultsPartial({ search }: SearchResultsPartialProps) {
|
||||
const [searching, setSearching] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 2000);
|
||||
useEffect(() => {
|
||||
setSearching(search.searchQuery !== "");
|
||||
setLoading(search.searchQuery !== "");
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const resultView = useMemo(() => {
|
||||
if (loading) return <SearchLoadingView />;
|
||||
if (searching) return <SearchResultsView searchQuery={debouncedSearch} />;
|
||||
return <HomeView />;
|
||||
}, [loading, searching, debouncedSearch]);
|
||||
|
||||
return resultView;
|
||||
}
|
102
src/views/search/SearchResultsView.tsx
Normal file
102
src/views/search/SearchResultsView.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { MWMassProviderOutput, MWQuery, SearchProviders } from "@/providers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchLoadingView } from "./SearchLoadingView";
|
||||
|
||||
function SearchSuffix(props: {
|
||||
fails: number;
|
||||
total: number;
|
||||
resultsSize: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const allFailed: boolean = props.fails === props.total;
|
||||
const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH;
|
||||
|
||||
return (
|
||||
<div className="my-24 flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<IconPatch
|
||||
icon={icon}
|
||||
className={`text-xl ${allFailed ? "text-red-400" : "text-bink-600"}`}
|
||||
/>
|
||||
|
||||
{/* standard suffix */}
|
||||
{!allFailed ? (
|
||||
<div>
|
||||
{props.fails > 0 ? (
|
||||
<p className="text-red-400">
|
||||
{t("search.providersFailed", {
|
||||
fails: props.fails,
|
||||
total: props.total,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{props.resultsSize > 0 ? (
|
||||
<p>{t("search.allResults")}</p>
|
||||
) : (
|
||||
<p>{t("search.noResults")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Error result */}
|
||||
{allFailed ? (
|
||||
<div>
|
||||
<p>{t("search.allFailed")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [results, setResults] = useState<MWMassProviderOutput | undefined>();
|
||||
const [runSearchQuery, loading, error] = useLoading((query: MWQuery) =>
|
||||
SearchProviders(query)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
async function runSearch(query: MWQuery) {
|
||||
const searchResults = await runSearchQuery(query);
|
||||
if (!searchResults) return;
|
||||
setResults(searchResults);
|
||||
}
|
||||
|
||||
if (searchQuery.searchQuery !== "") runSearch(searchQuery);
|
||||
}, [searchQuery, runSearchQuery]);
|
||||
|
||||
if (loading) return <SearchLoadingView />;
|
||||
if (error) return <SearchSuffix resultsSize={0} fails={1} total={1} />;
|
||||
if (!results) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{results?.results.length > 0 ? (
|
||||
<SectionHeading
|
||||
title={t("search.headingTitle") || "Search results"}
|
||||
icon={Icons.SEARCH}
|
||||
>
|
||||
{results.results.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
/>
|
||||
))}
|
||||
</SectionHeading>
|
||||
) : null}
|
||||
|
||||
<SearchSuffix
|
||||
resultsSize={results.results.length}
|
||||
fails={results.stats.failed}
|
||||
total={results.stats.total}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
54
src/views/search/SearchView.tsx
Normal file
54
src/views/search/SearchView.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { SearchBarInput } from "@/components/SearchBar";
|
||||
import Sticky from "react-stickynode";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SearchResultsPartial } from "./SearchResultsPartial";
|
||||
|
||||
export function SearchView() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery();
|
||||
const [showBg, setShowBg] = useState(false);
|
||||
|
||||
const stickStateChanged = useCallback(
|
||||
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED),
|
||||
[setShowBg]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10">
|
||||
<Navigation bg={showBg} />
|
||||
<ThinContainer>
|
||||
<div className="mt-44 space-y-16 text-center">
|
||||
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
|
||||
<div className="absolute bottom-4 h-[100vh] w-[300vh] rounded-[100%] bg-[#211D30]" />
|
||||
</div>
|
||||
<div className="relative z-20">
|
||||
<div className="mb-16">
|
||||
<Title>{t("search.title")}</Title>
|
||||
</div>
|
||||
<Sticky enabled top={16} onStateChange={stickStateChanged}>
|
||||
<SearchBarInput
|
||||
onChange={setSearch}
|
||||
value={search}
|
||||
onUnFocus={setSearchUnFocus}
|
||||
placeholder={
|
||||
t("search.placeholder") || "What do you want to watch?"
|
||||
}
|
||||
/>
|
||||
</Sticky>
|
||||
</div>
|
||||
</div>
|
||||
</ThinContainer>
|
||||
</div>
|
||||
<ThinContainer>
|
||||
<SearchResultsPartial search={search} />
|
||||
</ThinContainer>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import loadVersion from "vite-plugin-package-version";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), loadVersion()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
Loading…
Reference in New Issue
Block a user