move src back

This commit is contained in:
mrjvs
2022-03-13 19:08:45 +01:00
parent c4920125b8
commit 1336a0f12c
62 changed files with 0 additions and 0 deletions

28
src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { MWMediaType } from "providers";
import { Redirect, Route, Switch } from "react-router-dom";
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";
function App() {
return (
<WatchedContextProvider>
<BookmarkContextProvider>
<Switch>
<Route exact path="/">
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
</Route>
<Route exact path="/media/movie/:media" component={MediaView} />
<Route exact path="/media/series/:media" component={MediaView} />
<Route exact path="/search/:type/:query?" component={SearchView} />
<Route path="*" component={NotFoundPage} />
</Switch>
</BookmarkContextProvider>
</WatchedContextProvider>
);
}
export default App;

View File

@@ -0,0 +1,61 @@
import { Icon, Icons } from "components/Icon";
import React, { Fragment } from "react";
import { Listbox, Transition } from "@headlessui/react";
export interface OptionItem {
id: string;
name: string;
}
interface DropdownProps {
selectedItem: OptionItem;
setSelectedItem: (value: OptionItem) => void;
options: Array<OptionItem>;
}
export const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
(props: DropdownProps) => (
<div className="relative my-4 max-w-[18rem]">
<Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
{({ open }) => (
<>
<Listbox.Button className="bg-denim-500 focus-visible:ring-bink-500 focus-visible:ring-offset-bink-300 relative w-full cursor-default rounded-lg py-2 pl-3 pr-10 text-left text-white shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
<span className="block truncate">{props.selectedItem.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
icon={Icons.CHEVRON_DOWN}
className={`transform transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</span>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="bg-denim-500 scrollbar-thin scrollbar-track-denim-400 scrollbar-thumb-denim-200 absolute bottom-11 left-0 right-0 z-10 mt-1 max-h-60 overflow-auto rounded-md py-1 text-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:bottom-10 sm:text-sm">
{props.options.map((opt) => (
<Listbox.Option
className={({ active }) =>
`relative cursor-default select-none py-2 pl-10 pr-4 ${
active ? "bg-denim-400 text-bink-700" : "text-white"
}`
}
key={opt.id}
value={opt}
>
{opt.name}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</>
)}
</Listbox>
</div>
)
);

49
src/components/Icon.tsx Normal file
View File

@@ -0,0 +1,49 @@
export enum Icons {
SEARCH = "search",
BOOKMARK = "bookmark",
CLOCK = "clock",
EYE_SLASH = "eyeSlash",
ARROW_LEFT = "arrowLeft",
ARROW_RIGHT = "arrowRight",
CHEVRON_DOWN = "chevronDown",
CHEVRON_RIGHT = "chevronRight",
CLAPPER_BOARD = "clapperBoard",
FILM = "film",
DRAGON = "dragon",
WARNING = "warning",
MOVIE_WEB = "movieWeb",
DISCORD = "discord",
GITHUB = "github",
}
export interface IconProps {
icon: Icons;
className?: string;
}
const iconList: Record<Icons, string> = {
search: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M500.3 443.7l-119.7-119.7c27.22-40.41 40.65-90.9 33.46-144.7C401.8 87.79 326.8 13.32 235.2 1.723C99.01-15.51-15.51 99.01 1.724 235.2c11.6 91.64 86.08 166.7 177.6 178.9c53.8 7.189 104.3-6.236 144.7-33.46l119.7 119.7c15.62 15.62 40.95 15.62 56.57 0C515.9 484.7 515.9 459.3 500.3 443.7zM79.1 208c0-70.58 57.42-128 128-128s128 57.42 128 128c0 70.58-57.42 128-128 128S79.1 278.6 79.1 208z"/></svg>`,
bookmark: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M384 48V512l-192-112L0 512V48C0 21.5 21.5 0 48 0h288C362.5 0 384 21.5 384 48z"/></svg>`,
clock: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M256 512C114.6 512 0 397.4 0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512zM232 256C232 264 236 271.5 242.7 275.1L338.7 339.1C349.7 347.3 364.6 344.3 371.1 333.3C379.3 322.3 376.3 307.4 365.3 300L280 243.2V120C280 106.7 269.3 96 255.1 96C242.7 96 231.1 106.7 231.1 120L232 256z"/></svg>`,
eyeSlash: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M150.7 92.77C195 58.27 251.8 32 320 32C400.8 32 465.5 68.84 512.6 112.6C559.4 156 590.7 207.1 605.5 243.7C608.8 251.6 608.8 260.4 605.5 268.3C592.1 300.6 565.2 346.1 525.6 386.7L630.8 469.1C641.2 477.3 643.1 492.4 634.9 502.8C626.7 513.2 611.6 515.1 601.2 506.9L9.196 42.89C-1.236 34.71-3.065 19.63 5.112 9.196C13.29-1.236 28.37-3.065 38.81 5.112L150.7 92.77zM223.1 149.5L313.4 220.3C317.6 211.8 320 202.2 320 191.1C320 180.5 316.1 169.7 311.6 160.4C314.4 160.1 317.2 159.1 320 159.1C373 159.1 416 202.1 416 255.1C416 269.7 413.1 282.7 407.1 294.5L446.6 324.7C457.7 304.3 464 280.9 464 255.1C464 176.5 399.5 111.1 320 111.1C282.7 111.1 248.6 126.2 223.1 149.5zM320 480C239.2 480 174.5 443.2 127.4 399.4C80.62 355.1 49.34 304 34.46 268.3C31.18 260.4 31.18 251.6 34.46 243.7C44 220.8 60.29 191.2 83.09 161.5L177.4 235.8C176.5 242.4 176 249.1 176 255.1C176 335.5 240.5 400 320 400C338.7 400 356.6 396.4 373 389.9L446.2 447.5C409.9 467.1 367.8 480 320 480H320z"/></svg>`,
arrowLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-left"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>`,
chevronDown: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
chevronRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-right"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
clapperBoard: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M326.1 160l127.4-127.4C451.7 32.39 449.9 32 448 32h-86.06l-128 128H326.1zM166.1 160l128-128H201.9l-128 128H166.1zM497.7 56.19L393.9 160H512V96C512 80.87 506.5 67.15 497.7 56.19zM134.1 32H64C28.65 32 0 60.65 0 96v64h6.062L134.1 32zM0 416c0 35.35 28.65 64 64 64h384c35.35 0 64-28.65 64-64V192H0V416z"/></svg>`,
film: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M463.1 32h-416C21.49 32-.0001 53.49-.0001 80v352c0 26.51 21.49 48 47.1 48h416c26.51 0 48-21.49 48-48v-352C511.1 53.49 490.5 32 463.1 32zM111.1 408c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 408zM111.1 280c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM111.1 152c0 4.418-3.582 8-8 8H55.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8L111.1 152zM351.1 400c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V400zM351.1 208c0 8.836-7.164 16-16 16H175.1c-8.836 0-16-7.164-16-16v-96c0-8.838 7.164-16 16-16h160c8.836 0 16 7.162 16 16V208zM463.1 408c0 4.418-3.582 8-8 8h-47.1c-4.418 0-7.1-3.582-7.1-8l0-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V408zM463.1 280c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8v-48c0-4.418 3.582-8 8-8h47.1c4.418 0 8 3.582 8 8V280zM463.1 152c0 4.418-3.582 8-8 8h-47.1c-4.418 0-8-3.582-8-8l0-48c0-4.418 3.582-8 7.1-8h47.1c4.418 0 8 3.582 8 8V152z"/></svg>`,
dragon: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M18.43 255.8L192 224L100.8 292.6C90.67 302.8 97.8 320 112 320h222.7c-9.499-26.5-14.75-54.5-14.75-83.38V194.2L200.3 106.8C176.5 90.88 145 92.75 123.3 111.2l-117.5 116.4C-6.562 238 2.436 258 18.43 255.8zM575.2 289.9l-100.7-50.25c-16.25-8.125-26.5-24.75-26.5-43V160h63.99l28.12 22.62C546.1 188.6 554.2 192 562.7 192h30.1c11.1 0 23.12-6.875 28.5-17.75l14.37-28.62c5.374-10.87 4.25-23.75-2.999-33.5l-74.49-99.37C552.1 4.75 543.5 0 533.5 0H296C288.9 0 285.4 8.625 290.4 13.62L351.1 64L292.4 88.75c-5.874 3-5.874 11.37 0 14.37L351.1 128l-.0011 108.6c0 72 35.99 139.4 95.99 179.4c-195.6 6.75-344.4 41-434.1 60.88c-8.124 1.75-13.87 9-13.87 17.38C.0463 504 8.045 512 17.79 512h499.1c63.24 0 119.6-47.5 122.1-110.8C642.3 354 617.1 310.9 575.2 289.9zM489.1 66.25l45.74 11.38c-2.75 11-12.5 18.88-24.12 18.25C497.7 95.25 484.8 83.38 489.1 66.25z"/></svg>`,
warning: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-alert-triangle"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>`,
arrowRight: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>`,
movieWeb: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 20.927 20.927"><path d="M18.186,4.5V6.241H16.445V4.5H9.482V6.241H7.741V4.5H6V20.168H7.741V18.427H9.482v1.741h6.964V18.427h1.741v1.741h1.741V4.5Zm-8.7,12.186H7.741V14.945H9.482Zm0-3.482H7.741V11.464H9.482Zm0-3.482H7.741V7.982H9.482Zm8.7,6.964H16.445V14.945h1.741Zm0-3.482H16.445V11.464h1.741Zm0-3.482H16.445V7.982h1.741Z" transform="translate(10.018 -7.425) rotate(45)" fill="currentColor"/></svg>`,
discord: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"/></svg>`,
github: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 496 512"><!--! Font Awesome Pro 6.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>`,
};
export function Icon(props: IconProps) {
return (
<span
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
className={props.className}
/>
);
}

View File

@@ -0,0 +1,67 @@
import { useState } from "react";
import { MWMediaType, MWQuery } from "providers";
import { DropdownButton } from "./buttons/DropdownButton";
import { Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl";
export interface SearchBarProps {
buttonText?: string;
placeholder?: string;
onChange: (value: MWQuery) => void;
value: MWQuery;
}
export function SearchBarInput(props: SearchBarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
function setSearch(value: string) {
props.onChange({
...props.value,
searchQuery: value,
});
}
function setType(type: string) {
props.onChange({
...props.value,
type: type as MWMediaType,
});
}
return (
<div className="bg-denim-300 hover:bg-denim-400 focus-within:bg-denim-400 flex flex-col items-center gap-4 rounded-[28px] px-4 py-4 transition-colors sm:flex-row sm:py-2 sm:pl-8 sm:pr-2">
<TextInputControl
onChange={(val) => setSearch(val)}
value={props.value.searchQuery}
className="placeholder-denim-700 w-full flex-1 bg-transparent text-white focus:outline-none"
placeholder={props.placeholder}
/>
<DropdownButton
icon={Icons.SEARCH}
open={dropdownOpen}
setOpen={(val) => setDropdownOpen(val)}
selectedItem={props.value.type}
setSelectedItem={(val) => setType(val)}
options={[
{
id: MWMediaType.MOVIE,
name: "Movie",
icon: Icons.FILM,
},
{
id: MWMediaType.SERIES,
name: "Series",
icon: Icons.CLAPPER_BOARD,
},
{
id: MWMediaType.ANIME,
name: "Anime",
icon: Icons.DRAGON,
},
]}
onClick={() => setDropdownOpen((old) => !old)}
>
{props.buttonText || "Search"}
</DropdownButton>
</div>
);
}

View File

@@ -0,0 +1,17 @@
export interface ButtonControlProps {
onClick?: () => void;
children?: React.ReactNode;
className?: string;
}
export function ButtonControl({
onClick,
children,
className,
}: ButtonControlProps) {
return (
<button onClick={onClick} className={className} type="button">
{children}
</button>
);
}

View File

@@ -0,0 +1,129 @@
import { Icon, Icons } from "components/Icon";
import React, {
MouseEventHandler,
SyntheticEvent,
useEffect,
useState,
} from "react";
import { Backdrop, useBackdrop } from "components/layout/Backdrop";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
export interface OptionItem {
id: string;
name: string;
icon: Icons;
}
interface DropdownButtonProps extends ButtonControlProps {
icon: Icons;
open: boolean;
setOpen: (open: boolean) => void;
selectedItem: string;
setSelectedItem: (value: string) => void;
options: Array<OptionItem>;
}
export interface OptionProps {
option: OptionItem;
onClick: MouseEventHandler<HTMLDivElement>;
tabIndex?: number;
}
function Option({ option, onClick, tabIndex }: OptionProps) {
return (
<div
className="text-denim-700 flex h-10 cursor-pointer items-center space-x-2 px-4 py-2 text-left transition-colors hover:text-white"
onClick={onClick}
tabIndex={tabIndex}
>
<Icon icon={option.icon} />
<input type="radio" className="hidden" id={option.id} />
<label htmlFor={option.id} className="cursor-pointer ">
<div className="item">{option.name}</div>
</label>
</div>
);
}
export const DropdownButton = React.forwardRef<
HTMLDivElement,
DropdownButtonProps
>((props: DropdownButtonProps, ref) => {
const [setBackdrop, backdropProps, highlightedProps] = useBackdrop();
const [delayedSelectedId, setDelayedSelectedId] = useState(
props.selectedItem
);
useEffect(() => {
let id: NodeJS.Timeout;
if (props.open) {
setDelayedSelectedId(props.selectedItem);
} else {
id = setTimeout(() => {
setDelayedSelectedId(props.selectedItem);
}, 200);
}
return () => {
if (id) clearTimeout(id);
};
/* eslint-disable-next-line */
}, [props.open]);
const selectedItem: OptionItem = props.options.find(
(opt) => opt.id === props.selectedItem
) || { id: "movie", name: "movie", icon: Icons.ARROW_LEFT };
useEffect(() => {
setBackdrop(props.open);
/* eslint-disable-next-line */
}, [props.open]);
const onOptionClick = (e: SyntheticEvent, option: OptionItem) => {
e.stopPropagation();
props.setSelectedItem(option.id);
props.setOpen(false);
};
return (
<div className="w-full min-w-[140px] sm:w-auto">
<div
ref={ref}
className="relative w-full sm:w-auto"
{...highlightedProps}
>
<ButtonControl
{...props}
className="sm:justify-left bg-bink-200 hover:bg-bink-300 relative z-20 flex h-10 w-full items-center justify-center space-x-2 rounded-[20px] px-4 py-2 text-white"
>
<Icon icon={selectedItem.icon} />
<span className="flex-1">{selectedItem.name}</span>
<Icon
icon={Icons.CHEVRON_DOWN}
className={`transition-transform ${props.open ? "rotate-180" : ""}`}
/>
</ButtonControl>
<div
className={`bg-denim-300 absolute top-0 z-10 w-full rounded-[20px] pt-[40px] transition-all duration-200 ${
props.open
? "block max-h-60 opacity-100"
: "invisible max-h-0 opacity-0"
}`}
>
{props.options
.filter((opt) => opt.id !== delayedSelectedId)
.map((opt) => (
<Option
option={opt}
key={opt.id}
onClick={(e) => onOptionClick(e, opt)}
tabIndex={props.open ? 0 : undefined}
/>
))}
</div>
</div>
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
</div>
);
});

View File

@@ -0,0 +1,18 @@
import { Icon, Icons } from "components/Icon";
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
export interface IconButtonProps extends ButtonControlProps {
icon: Icons;
}
export function IconButton(props: IconButtonProps) {
return (
<ButtonControl
{...props}
className="flex items-center px-4 py-2 space-x-2 bg-bink-200 hover:bg-bink-300 text-white rounded-full"
>
<Icon icon={props.icon} />
<span>{props.children}</span>
</ButtonControl>
);
}

View File

@@ -0,0 +1,25 @@
import { Icon, Icons } from "components/Icon";
export interface IconPatchProps {
active?: boolean;
onClick?: () => void;
clickable?: boolean;
className?: string;
icon: Icons;
}
export function IconPatch(props: IconPatchProps) {
return (
<div className={props.className || undefined} onClick={props.onClick}>
<div
className={`bg-denim-300 flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent transition-[color,transform,border-color] duration-75 ${
props.clickable
? "hover:bg-denim-400 m-2 cursor-pointer hover:scale-110 hover:text-white active:scale-125"
: ""
} ${props.active ? "text-bink-600 border-bink-600 bg-bink-100" : ""}`}
>
<Icon icon={props.icon} />
</div>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useFade } from "hooks/useFade";
import { useEffect, useState } from "react";
interface BackdropProps {
onClick?: (e: MouseEvent) => void;
onBackdropHide?: () => void;
active?: boolean;
}
export function useBackdrop(): [
(state: boolean) => void,
BackdropProps,
{ style: any }
] {
const [backdrop, setBackdropState] = useState(false);
const [isHighlighted, setisHighlighted] = useState(false);
const setBackdrop = (state: boolean) => {
setBackdropState(state);
if (state) setisHighlighted(true);
};
const backdropProps: BackdropProps = {
active: backdrop,
onBackdropHide() {
setisHighlighted(false);
},
};
const highlightedProps = {
style: isHighlighted
? {
zIndex: "1000",
position: "relative",
}
: {},
};
return [setBackdrop, backdropProps, highlightedProps];
}
export function Backdrop(props: BackdropProps) {
const clickEvent = props.onClick || (() => {});
const animationEvent = props.onBackdropHide || (() => {});
const [isVisible, setVisible, fadeProps] = useFade();
useEffect(() => {
setVisible(!!props.active);
/* eslint-disable-next-line */
}, [props.active, setVisible]);
useEffect(() => {
if (!isVisible) animationEvent();
/* eslint-disable-next-line */
}, [isVisible]);
if (!isVisible) return null;
return (
<div
className={`fixed top-0 left-0 right-0 z-[999] h-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
!isVisible ? "opacity-0" : ""
}`}
{...fadeProps}
onClick={(e) => clickEvent(e.nativeEvent)}
/>
);
}

View File

@@ -0,0 +1,16 @@
import { Icon, Icons } from "components/Icon";
export function BrandPill(props: { clickable?: boolean }) {
return (
<div
className={`bg-bink-100 text-bink-600 flex items-center space-x-2 rounded-full bg-opacity-50 px-4 py-2 ${
props.clickable
? "hover:bg-bink-200 hover:text-bink-700 transition-[transform,background-color] hover:scale-105 active:scale-95"
: ""
}`}
>
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
<span className="font-semibold text-white">movie-web</span>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Link } from "components/text/Link";
import { Title } from "components/text/Title";
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
import { Component } from "react";
interface ErrorBoundaryState {
hasError: boolean;
error?: {
name: string;
description: string;
path: string;
};
}
export class ErrorBoundary extends Component<
Record<string, unknown>,
ErrorBoundaryState
> {
constructor(props: { children: any }) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error: any, errorInfo: any) {
console.error("Render error caught", error, errorInfo);
if (error instanceof Error) {
const realError: Error = error as Error;
this.setState((s) => ({
...s,
hasError: true,
error: {
name: realError.name,
description: realError.message,
path: errorInfo.componentStack.split("\n")[1],
},
}));
}
}
render() {
if (!this.state.hasError) return this.props.children;
return (
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12">
<div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Whoops, it broke</Title>
<p className="my-6 max-w-lg">
The app encountered an error and wasn&apos;t able to recover, please
report it to the{" "}
<Link url={DISCORD_LINK} newTab>
Discord server
</Link>{" "}
or on{" "}
<Link url={GITHUB_LINK} newTab>
GitHub
</Link>
.
</p>
</div>
{this.state.error ? (
<div className="bg-denim-300 w-4xl mt-12 max-w-full rounded px-6 py-4">
<p className="mb-1 break-words font-bold text-white">
{this.state.error.name} - {this.state.error.description}
</p>
<p className="break-words">{this.state.error.path}</p>
</div>
) : null}
</div>
);
}
}

View File

@@ -0,0 +1,22 @@
export interface LoadingProps {
text?: string;
className?: string;
}
export function Loading(props: LoadingProps) {
return (
<div className={props.className}>
<div className="flex flex-col items-center justify-center">
<div className="flex h-12 items-center justify-center">
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:300ms]" />
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:450ms]" />
</div>
{props.text && props.text.length ? (
<p className="mt-3 max-w-xs text-sm opacity-75">{props.text}</p>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { BrandPill } from "./BrandPill";
export interface NavigationProps {
children?: ReactNode;
}
export function Navigation(props: NavigationProps) {
return (
<div className="absolute left-0 right-0 top-0 flex items-center justify-between py-5 px-7">
<div className="flex items-center justify-center">
<div className="mr-6">
<Link to="/">
<BrandPill clickable />
</Link>
</div>
{props.children}
</div>
<div className="flex">
<a
href={DISCORD_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.DISCORD} clickable />
</a>
<a
href={GITHUB_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.GITHUB} clickable />
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { ReactNode } from "react";
export interface PaperProps {
children?: ReactNode,
className?: string,
}
export function Paper(props: PaperProps) {
return (
<div className={`bg-denim-200 rounded-xl p-12 ${props.className}`}>
{props.children}
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Dropdown, OptionItem } from "components/Dropdown";
import { Icons } from "components/Icon";
import { WatchedEpisode } from "components/media/WatchedEpisodeButton";
import { useLoading } from "hooks/useLoading";
import { serializePortableMedia } from "hooks/usePortableMedia";
import {
convertMediaToPortable,
MWMedia,
MWMediaSeasons,
MWMediaSeason,
MWPortableMedia,
} from "providers";
import { getSeasonDataFromMedia } from "providers/methods/seasons";
import { useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
export interface SeasonsProps {
media: MWMedia;
}
export function LoadingSeasons(props: { error?: boolean }) {
return (
<div>
<div>
<div className="bg-denim-400 mb-3 mt-5 h-10 w-56 rounded opacity-50" />
</div>
{!props.error ? (
<>
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
<div className="bg-denim-400 mr-3 mb-3 inline-block h-10 w-10 rounded opacity-50" />
</>
) : (
<div className="flex items-center space-x-3">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p>Failed to load seasons and episodes</p>
</div>
)}
</div>
);
}
export function Seasons(props: SeasonsProps) {
const [searchSeasons, loading, error, success] = useLoading(
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
);
const history = useHistory();
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
const seasonSelected = props.media.seasonId as string;
const episodeSelected = props.media.episodeId as string;
useEffect(() => {
(async () => {
const seasonData = await searchSeasons(props.media);
setSeasons(seasonData);
})();
}, [searchSeasons, props.media]);
function navigateToSeasonAndEpisode(seasonId: string, episodeId: string) {
const newMedia: MWMedia = { ...props.media };
newMedia.episodeId = episodeId;
newMedia.seasonId = seasonId;
history.replace(
`/media/${newMedia.mediaType}/${serializePortableMedia(
convertMediaToPortable(newMedia)
)}`
);
}
const mapSeason = (season: MWMediaSeason) => ({
id: season.id,
name: season.title || `Season ${season.sort}`,
});
const options = seasons.seasons.map(mapSeason);
const foundSeason = seasons.seasons.find(
(season) => season.id === seasonSelected
);
const selectedItem = foundSeason ? mapSeason(foundSeason) : null;
return (
<>
{loading ? <LoadingSeasons /> : null}
{error ? <LoadingSeasons error /> : null}
{success && seasons.seasons.length ? (
<>
<Dropdown
selectedItem={selectedItem as OptionItem}
options={options}
setSelectedItem={(seasonItem) =>
navigateToSeasonAndEpisode(
seasonItem.id,
seasons.seasons.find((s) => s.id === seasonItem.id)?.episodes[0]
.id as string
)
}
/>
{seasons.seasons
.find((s) => s.id === seasonSelected)
?.episodes.map((v) => (
<WatchedEpisode
key={v.id}
media={{
...props.media,
seriesData: seasons,
episodeId: v.id,
seasonId: seasonSelected,
}}
active={v.id === episodeSelected}
onClick={() => navigateToSeasonAndEpisode(seasonSelected, v.id)}
/>
))}
</>
) : null}
</>
);
}

View File

@@ -0,0 +1,37 @@
import { Icon, Icons } from "components/Icon";
import { ArrowLink } from "components/text/ArrowLink";
import { ReactNode } from "react";
interface SectionHeadingProps {
icon?: Icons;
title: string;
children?: ReactNode;
linkText?: string;
onClick?: () => void;
className?: string;
}
export function SectionHeading(props: SectionHeadingProps) {
return (
<div className={`mt-12 ${props.className}`}>
<div className="mb-4 flex items-end">
<p className="text-denim-700 flex flex-1 items-center font-bold uppercase">
{props.icon ? (
<span className="mr-2 text-xl">
<Icon icon={props.icon} />
</span>
) : null}
{props.title}
</p>
{props.linkText ? (
<ArrowLink
linkText={props.linkText}
direction="left"
onClick={props.onClick}
/>
) : null}
</div>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { ReactNode } from "react";
interface ThinContainerProps {
classNames?: string;
children?: ReactNode;
}
export function ThinContainer(props: ThinContainerProps) {
return (
<div
className={`max-w-[600px] mx-auto px-2 sm:px-0 ${props.classNames || ""}`}
>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,25 @@
export interface EpisodeProps {
progress?: number;
episodeNumber: number;
onClick?: () => void;
active?: boolean;
}
export function Episode(props: EpisodeProps) {
return (
<div
onClick={props.onClick}
className={`bg-denim-500 hover:bg-denim-400 transition-[background-color, transform, box-shadow] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110 ${
props.active ? "shadow-bink-500 shadow-[inset_0_0_0_2px]" : ""
}`}
>
<div
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
style={{
width: `${props.progress || 0}%`,
}}
/>
<span className="relative">{props.episodeNumber}</span>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import {
convertMediaToPortable,
getProviderFromId,
MWMediaMeta,
MWMediaType,
} from "providers";
import { Link } from "react-router-dom";
import { Icon, Icons } from "components/Icon";
import { serializePortableMedia } from "hooks/usePortableMedia";
import { DotList } from "components/text/DotList";
export interface MediaCardProps {
media: MWMediaMeta;
watchedPercentage: number;
linkable?: boolean;
series?: boolean;
}
function MediaCardContent({
media,
linkable,
watchedPercentage,
series,
}: MediaCardProps) {
const provider = getProviderFromId(media.providerId);
if (!provider) {
return null;
}
return (
<article
className={`bg-denim-300 group relative mb-4 flex overflow-hidden rounded py-4 px-5 ${
linkable ? "hover:bg-denim-400" : ""
}`}
>
{/* progress background */}
{watchedPercentage > 0 ? (
<div className="absolute top-0 left-0 right-0 bottom-0">
<div
className="bg-bink-300 relative h-full bg-opacity-30"
style={{
width: `${watchedPercentage}%`,
}}
>
<div className="from-bink-400 absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l to-transparent opacity-40" />
</div>
</div>
) : null}
<div className="relative flex flex-1">
{/* card content */}
<div className="flex-1">
<h1 className="mb-1 font-bold text-white">
{media.title}
{series && media.seasonId && media.episodeId ? (
<span className="text-denim-700 ml-2 text-xs">
S{media.seasonId} E{media.episodeId}
</span>
) : null}
</h1>
<DotList
className="text-xs"
content={[provider.displayName, media.mediaType, media.year]}
/>
</div>
{/* hoverable chevron */}
<div
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
}`}
>
<Icon icon={Icons.CHEVRON_RIGHT} />
</div>
</div>
</article>
);
}
export function MediaCard(props: MediaCardProps) {
let link = "movie";
if (props.media.mediaType === MWMediaType.SERIES) link = "series";
const content = <MediaCardContent {...props} />;
if (!props.linkable) return <span>{content}</span>;
return (
<Link
to={`/media/${link}/${serializePortableMedia(
convertMediaToPortable(props.media)
)}`}
>
{content}
</Link>
);
}

View File

@@ -0,0 +1,84 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Loading } from "components/layout/Loading";
import { MWMediaCaption, MWMediaStream } from "providers";
import { ReactElement, useEffect, useRef, useState } from "react";
export interface VideoPlayerProps {
source: MWMediaStream;
captions: MWMediaCaption[];
startAt?: number;
onProgress?: (event: ProgressEvent) => void;
}
export function SkeletonVideoPlayer(props: { error?: boolean }) {
return (
<div className="bg-denim-200 flex aspect-video w-full items-center justify-center rounded-xl">
{props.error ? (
<div className="flex flex-col items-center">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p className="mt-5 text-white">Couldn&apos;t get your stream</p>
</div>
) : (
<div className="flex flex-col items-center">
<Loading />
<p className="mt-3 text-white">Getting your stream...</p>
</div>
)}
</div>
);
}
export function VideoPlayer(props: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [hasErrored, setErrored] = useState(false);
const [isLoading, setLoading] = useState(true);
const showVideo = !isLoading && !hasErrored;
const mustUseHls = props.source.type === "m3u8";
// reset if stream url changes
useEffect(() => {
setLoading(true);
setErrored(false);
}, [props.source.url]);
let skeletonUi: null | ReactElement = null;
if (hasErrored) {
skeletonUi = <SkeletonVideoPlayer error />;
} else if (isLoading) {
skeletonUi = <SkeletonVideoPlayer />;
}
return (
<>
{skeletonUi}
<video
className={`bg-denim-500 w-full rounded-xl ${
!showVideo ? "hidden" : ""
}`}
ref={videoRef}
onProgress={(e) =>
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
}
onLoadedData={(e) => {
setLoading(false);
if (props.startAt)
(e.target as HTMLVideoElement).currentTime = props.startAt;
}}
onError={(e) => {
console.error("failed to playback stream", e);
setErrored(true);
}}
controls
autoPlay
>
{!mustUseHls ? (
<source src={props.source.url} type="video/mp4" />
) : null}
{props.captions.map((v) => (
<track key={v.id} kind="captions" label={v.label} src={v.url} />
))}
</video>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { getEpisodeFromMedia, MWMedia } from "providers";
import { useWatchedContext, getWatchedFromPortable } from "state/watched";
import { Episode } from "./EpisodeButton";
export interface WatchedEpisodeProps {
media: MWMedia;
onClick?: () => void;
active?: boolean;
}
export function WatchedEpisode(props: WatchedEpisodeProps) {
const { watched } = useWatchedContext();
const foundWatched = getWatchedFromPortable(watched.items, props.media);
const episode = getEpisodeFromMedia(props.media);
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
return (
<Episode
progress={watchedPercentage}
episodeNumber={episode?.episode?.sort ?? 1}
active={props.active}
onClick={props.onClick}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { MWMediaMeta } from "providers";
import { useWatchedContext, getWatchedFromPortable } from "state/watched";
import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps {
media: MWMediaMeta;
series?: boolean;
}
export function WatchedMediaCard(props: WatchedMediaCardProps) {
const { watched } = useWatchedContext();
const foundWatched = getWatchedFromPortable(watched.items, props.media);
const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
return (
<MediaCard
watchedPercentage={watchedPercentage}
media={props.media}
series={props.series && props.media.episodeId !== undefined}
linkable
/>
);
}

View File

@@ -0,0 +1,39 @@
export interface TextInputControlPropsNoLabel {
onChange?: (data: string) => void;
value?: string;
placeholder?: string;
className?: string;
}
export interface TextInputControlProps extends TextInputControlPropsNoLabel {
label?: string;
}
export function TextInputControl({
onChange,
value,
label,
className,
placeholder,
}: TextInputControlProps) {
const input = (
<input
type="text"
className={className}
placeholder={placeholder}
onChange={(e) => onChange && onChange(e.target.value)}
value={value}
/>
);
if (label) {
return (
<label>
<span>{label}</span>
{input}
</label>
);
}
return input;
}

View File

@@ -0,0 +1,53 @@
import { Icon, Icons } from "components/Icon";
import { Link as LinkRouter } from "react-router-dom";
interface IArrowLinkPropsBase {
linkText: string;
className?: string;
onClick?: () => void;
direction?: "left" | "right";
}
interface IArrowLinkPropsExternal extends IArrowLinkPropsBase {
url: string;
}
interface IArrowLinkPropsInternal extends IArrowLinkPropsBase {
to: string;
}
export type ArrowLinkProps =
| IArrowLinkPropsExternal
| IArrowLinkPropsInternal
| IArrowLinkPropsBase;
export function ArrowLink(props: ArrowLinkProps) {
const direction = props.direction || "right";
const isExternal = !!(props as IArrowLinkPropsExternal).url;
const isInternal = !!(props as IArrowLinkPropsInternal).to;
const content = (
<span className="text-bink-600 hover:text-bink-700 group inline-flex cursor-pointer items-center space-x-1 font-bold active:scale-95">
{direction === "left" ? (
<span className="text-xl transition-transform group-hover:-translate-x-1">
<Icon icon={Icons.ARROW_LEFT} />
</span>
) : null}
<span className="flex-1">{props.linkText}</span>
{direction === "right" ? (
<span className="text-xl transition-transform group-hover:translate-x-1">
<Icon icon={Icons.ARROW_RIGHT} />
</span>
) : null}
</span>
);
if (isExternal)
return <a href={(props as IArrowLinkPropsExternal).url}>{content}</a>;
if (isInternal)
return (
<LinkRouter to={(props as IArrowLinkPropsInternal).to}>{content}</LinkRouter>
);
return (
<span onClick={() => props.onClick && props.onClick()}>{content}</span>
);
}

View File

@@ -0,0 +1,19 @@
export interface DotListProps {
content: string[];
className?: string;
}
export function DotList(props: DotListProps) {
return (
<p className={`text-denim-700 font-semibold ${props.className || ""}`}>
{props.content.map((item, index) => (
<span key={item}>
{index !== 0 ? (
<span className="mx-[0.6em] text-[1em]">&#9679;</span>
) : null}
{item}
</span>
))}
</p>
);
}

View File

@@ -0,0 +1,42 @@
import { ReactNode } from "react";
import { Link as LinkRouter } from "react-router-dom";
interface ILinkPropsBase {
children?: ReactNode;
className?: string;
onClick?: () => void;
}
interface ILinkPropsExternal extends ILinkPropsBase {
url: string;
newTab?: boolean;
}
interface ILinkPropsInternal extends ILinkPropsBase {
to: string;
}
type LinkProps =
| ILinkPropsExternal
| ILinkPropsInternal
| ILinkPropsBase;
export function Link(props: LinkProps) {
const isExternal = !!(props as ILinkPropsExternal).url;
const isInternal = !!(props as ILinkPropsInternal).to;
const content = (
<span className="text-bink-600 hover:text-bink-700 cursor-pointer font-bold">
{props.children}
</span>
);
if (isExternal)
return <a target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined} rel="noreferrer" href={(props as ILinkPropsExternal).url}>{content}</a>;
if (isInternal)
return (
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter>
);
return (
<span onClick={() => props.onClick && props.onClick()}>{content}</span>
);
}

View File

@@ -0,0 +1,7 @@
export interface TaglineProps {
children?: React.ReactNode;
}
export function Tagline(props: TaglineProps) {
return <p className="font-bold text-bink-600">{props.children}</p>;
}

View File

@@ -0,0 +1,7 @@
export interface TitleProps {
children?: React.ReactNode;
}
export function Title(props: TitleProps) {
return <h1 className="text-4xl font-bold text-white">{props.children}</h1>;
}

20
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(
() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
},
[value, delay]
);
return debouncedValue;
}

17
src/hooks/useFade.css Normal file
View File

@@ -0,0 +1,17 @@
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}

27
src/hooks/useFade.ts Normal file
View File

@@ -0,0 +1,27 @@
import React, { useEffect, useState } from "react";
import './useFade.css'
export const useFade = (initial = false): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => {
const [show, setShow] = useState<boolean>(initial);
const [isVisible, setVisible] = useState<boolean>(show);
// Update visibility when show changes
useEffect(() => {
if (show) setVisible(true);
}, [show]);
// When the animation finishes, set visibility to false
const onAnimationEnd = () => {
if (!show) setVisible(false);
};
const style = { animation: `${show ? "fadeIn" : "fadeOut"} .3s` };
// These props go on the fading DOM element
const fadeProps = {
style,
onAnimationEnd
};
return [isVisible, setShow, fadeProps];
};

47
src/hooks/useLoading.ts Normal file
View File

@@ -0,0 +1,47 @@
import React, { useMemo, useRef, useState } from "react";
export function useLoading<T extends (...args: any) => Promise<any>>(
action: T
) {
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<any | undefined>(undefined);
const isMounted = useRef(true);
// we want action to be memoized forever
const actionMemo = useMemo(() => action, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
const doAction = useMemo(
() =>
async (...args: Parameters<T>) => {
setLoading(true);
setSuccess(false);
setError(undefined);
return new Promise((resolve) => {
actionMemo(...args)
.then((v) => {
if (!isMounted.current) return resolve(undefined);
setSuccess(true);
resolve(v);
return null;
})
.catch((err) => {
if (isMounted) {
setError(err);
setSuccess(false);
}
resolve(undefined);
});
}).finally(() => isMounted.current && setLoading(false));
},
[actionMemo]
);
return [doAction, loading, error, success];
}

View File

@@ -0,0 +1,30 @@
import { MWPortableMedia } from "providers";
import { useEffect, useState } from "react";
import { useParams } from "react-router";
export function deserializePortableMedia(media: string): MWPortableMedia {
return JSON.parse(atob(decodeURIComponent(media)));
}
export function serializePortableMedia(media: MWPortableMedia): string {
const data = encodeURIComponent(btoa(JSON.stringify(media)));
return data;
}
export function usePortableMedia(): MWPortableMedia | undefined {
const { media } = useParams<{ media: string }>();
const [mediaObject, setMediaObject] = useState<MWPortableMedia | undefined>(
undefined
);
useEffect(() => {
try {
setMediaObject(deserializePortableMedia(media));
} catch (err) {
console.error("Failed to deserialize portable media", err);
setMediaObject(undefined);
}
}, [media, setMediaObject]);
return mediaObject;
}

View File

@@ -0,0 +1,34 @@
import { MWMediaType, MWQuery } from "providers";
import React, { useState } from "react";
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] {
const history = useHistory();
const { path, params } = useRouteMatch<{ type: string; query: string }>();
const [search, setSearch] = useState<MWQuery>({
searchQuery: "",
type: MWMediaType.MOVIE,
});
const updateParams = (inp: Partial<MWQuery>) => {
const copySearch: MWQuery = { ...search };
Object.assign(copySearch, inp);
history.replace(
generatePath(path, {
query:
copySearch.searchQuery.length === 0 ? undefined : inp.searchQuery,
type: copySearch.type,
})
);
};
React.useEffect(() => {
const type =
Object.values(MWMediaType).find((v) => params.type === v) ||
MWMediaType.MOVIE;
const searchQuery = params.query || "";
setSearch({ type, searchQuery });
}, [params, setSearch]);
return [search, updateParams];
}

16
src/index.css Normal file
View File

@@ -0,0 +1,16 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
@apply bg-denim-100 text-denim-700 font-open-sans min-h-screen;
}
#root {
display: flex;
justify-content: flex-start;
align-items: flex-start;
min-height: 100vh;
width: 100%;
}

17
src/index.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React 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";
ReactDOM.render(
<React.StrictMode>
<ErrorBoundary>
<HashRouter>
<App />
</HashRouter>
</ErrorBoundary>
</React.StrictMode>,
document.getElementById("root")
);

3
src/mw_constants.ts Normal file
View File

@@ -0,0 +1,3 @@
export const CORS_PROXY_URL = "https://proxy-1.movie-web.workers.dev/?destination=";
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb";
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web";

31
src/providers/README.md Normal file
View File

@@ -0,0 +1,31 @@
# the providers
to make this as clear as possible, here is some extra information on how the interal system works regarding providers.
| Term | explanation |
| ------------- | ------------------------------------------------------------------------------------- |
| Media | Object containing information about a piece of media. like title and its id's |
| PortableMedia | Object with just the identifiers of a piece of media. used for transport and saving |
| MediaStream | Object with a stream url in it. use it to view a piece of media. |
| Provider | group of methods to generate media and mediastreams from a source. aliased as scraper |
All types are prefixed with MW (MovieWeb) to prevent clashing names.
## Some rules
1. **Never** remove a provider completely if it's been in use before. just disable it.
2. **Never** change the ID of a provider if it's been in use before.
3. **Never** change system of the media ID of a provider without making it backwards compatible
All these rules are because `PortableMedia` objects need to stay functional. because:
- It's used for routing, links would stop working
- It's used for storage, continue watching and bookmarks would stop working
# The list of providers and their quirks
Some providers have quirks, stuff they do differently than other providers
## TheFlix
- for series, the latest episode released will be one playing at first when you select it from search results

42
src/providers/index.ts Normal file
View File

@@ -0,0 +1,42 @@
import { getProviderFromId } from "./methods/helpers";
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types";
export * from "./types";
export * from "./methods/helpers";
export * from "./methods/providers";
export * from "./methods/search";
/*
** Turn media object into a portable media object
*/
export function convertMediaToPortable(media: MWMedia): MWPortableMedia {
return {
mediaId: media.mediaId,
providerId: media.providerId,
mediaType: media.mediaType,
episodeId: media.episodeId,
seasonId: media.seasonId,
};
}
/*
** Turn portable media into media object
*/
export async function convertPortableToMedia(
portable: MWPortableMedia
): Promise<MWMedia | undefined> {
const provider = getProviderFromId(portable.providerId);
return provider?.getMediaFromPortable(portable);
}
/*
** find provider from portable and get stream from that provider
*/
export async function getStream(
media: MWPortableMedia
): Promise<MWMediaStream | undefined> {
const provider = getProviderFromId(media.providerId);
if (!provider) return undefined;
return provider.getStream(media);
}

View File

@@ -0,0 +1,113 @@
import {
MWMediaProvider,
MWMediaType,
MWPortableMedia,
MWMediaStream,
MWQuery,
MWMediaSeasons,
} from "providers/types";
import {
searchTheFlix,
getDataFromSearch,
turnDataIntoMedia,
} from "providers/list/theflix/search";
import { getDataFromPortableSearch } from "providers/list/theflix/portableToMedia";
import { MWProviderMediaResult } from "providers";
import { CORS_PROXY_URL } from "mw_constants";
export const theFlixScraper: MWMediaProvider = {
id: "theflix",
enabled: true,
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
displayName: "theflix",
async getMediaFromPortable(
media: MWPortableMedia
): Promise<MWProviderMediaResult> {
const data: any = await getDataFromPortableSearch(media);
return {
...media,
year: new Date(data.releaseDate).getFullYear().toString(),
title: data.name,
};
},
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
const searchRes = await searchTheFlix(query);
const searchData = await getDataFromSearch(searchRes, 10);
const results: MWProviderMediaResult[] = [];
for (const item of searchData) {
results.push(turnDataIntoMedia(item));
}
return results;
},
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
let url = "";
if (media.mediaType === MWMediaType.MOVIE) {
url = `${CORS_PROXY_URL}https://theflix.to/movie/${media.mediaId}?movieInfo=${media.mediaId}`;
} else if (media.mediaType === MWMediaType.SERIES) {
url = `${CORS_PROXY_URL}https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
}
const res = await fetch(url).then((d) => d.text());
const prop: HTMLElement | undefined = Array.from(
new DOMParser()
.parseFromString(res, "text/html")
.querySelectorAll("script")
).find((e) => e.textContent?.includes("theflixvd.b-cdn"));
if (!prop || !prop.textContent) {
throw new Error("Could not find stream");
}
const data = JSON.parse(prop.textContent);
return { url: data.props.pageProps.videoUrl, type: "mp4", captions: [] };
},
async getSeasonDataFromMedia(
media: MWPortableMedia
): Promise<MWMediaSeasons> {
const url = `${CORS_PROXY_URL}https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
const res = await fetch(url).then((d) => d.text());
const node: Element = Array.from(
new DOMParser()
.parseFromString(res, "text/html")
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
)[0];
let data = JSON.parse(node.innerHTML).props.pageProps.selectedTv.seasons;
data = data.filter((season: any) => season.releaseDate != null);
data = data.map((season: any) => {
const episodes = season.episodes.filter(
(episode: any) => episode.releaseDate != null
);
return { ...season, episodes };
});
return {
seasons: data.map((d: any) => ({
sort: d.seasonNumber === 0 ? 999 : d.seasonNumber,
id: d.seasonNumber.toString(),
type: d.seasonNumber === 0 ? "special" : "season",
title: d.name,
episodes: d.episodes.map((e: any) => ({
title: e.name,
sort: e.episodeNumber,
id: e.episodeNumber.toString(),
episodeNumber: e.episodeNumber,
})),
})),
};
},
};

View File

@@ -0,0 +1,36 @@
import { CORS_PROXY_URL } from "mw_constants";
import { MWMediaType, MWPortableMedia } from "providers/types";
const getTheFlixUrl = (media: MWPortableMedia, params?: URLSearchParams) => {
if (media.mediaType === MWMediaType.MOVIE) {
return `https://theflix.to/movie/${media.mediaId}?${params}`;
}
if (media.mediaType === MWMediaType.SERIES) {
return `https://theflix.to/tv-show/${media.mediaId}/season-${media.seasonId}/episode-${media.episodeId}`;
}
return "";
};
export async function getDataFromPortableSearch(
media: MWPortableMedia
): Promise<any> {
const params = new URLSearchParams();
params.append("movieInfo", media.mediaId);
const res = await fetch(CORS_PROXY_URL + getTheFlixUrl(media, params)).then(
(d) => d.text()
);
const node: Element = Array.from(
new DOMParser()
.parseFromString(res, "text/html")
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
)[0];
if (media.mediaType === MWMediaType.MOVIE) {
return JSON.parse(node.innerHTML).props.pageProps.movie;
}
// must be series here
return JSON.parse(node.innerHTML).props.pageProps.selectedTv;
}

View File

@@ -0,0 +1,48 @@
import { CORS_PROXY_URL } from "mw_constants";
import { MWMediaType, MWProviderMediaResult, MWQuery } from "providers";
const getTheFlixUrl = (type: "tv-shows" | "movies", params: URLSearchParams) =>
`https://theflix.to/${type}/trending?${params}`;
export function searchTheFlix(query: MWQuery): Promise<string> {
const params = new URLSearchParams();
params.append("search", query.searchQuery);
return fetch(
CORS_PROXY_URL +
getTheFlixUrl(
query.type === MWMediaType.MOVIE ? "movies" : "tv-shows",
params
)
).then((d) => d.text());
}
export function getDataFromSearch(page: string, limit = 10): any[] {
const node: Element = Array.from(
new DOMParser()
.parseFromString(page, "text/html")
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
)[0];
const data = JSON.parse(node.innerHTML);
return data.props.pageProps.mainList.docs
.filter((d: any) => d.available)
.slice(0, limit);
}
export function turnDataIntoMedia(data: any): MWProviderMediaResult {
return {
mediaId: `${data.id}-${data.name
.replace(/[^a-z0-9]+|\s+/gim, " ")
.trim()
.replace(/\s+/g, "-")
.toLowerCase()}`,
title: data.name,
year: new Date(data.releaseDate).getFullYear().toString(),
seasonCount: data.numberOfSeasons,
episodeId: data.lastReleasedEpisode
? data.lastReleasedEpisode.episodeNumber.toString()
: null,
seasonId: data.lastReleasedEpisode
? data.lastReleasedEpisode.seasonNumber.toString()
: null,
};
}

View File

@@ -0,0 +1,9 @@
import { SimpleCache } from "utils/cache";
import { MWPortableMedia, MWMedia } from "providers";
// cache
const contentCache = new SimpleCache<MWPortableMedia, MWMedia>();
contentCache.setCompare((a,b) => a.mediaId === b.mediaId && a.providerId === b.providerId);
contentCache.initialize();
export default contentCache;

View File

@@ -0,0 +1,65 @@
import { MWMediaType, MWMediaProviderMetadata } from "providers";
import { MWMedia, MWMediaEpisode, MWMediaSeason } from "providers/types";
import { mediaProviders, mediaProvidersUnchecked } from "./providers";
/*
** Fetch all enabled providers for a specific type
*/
export function GetProvidersForType(type: MWMediaType) {
return mediaProviders.filter((v) => v.type.includes(type));
}
/*
** Get a provider by a id
*/
export function getProviderFromId(id: string) {
return mediaProviders.find((v) => v.id === id);
}
/*
** Get a provider metadata
*/
export function getProviderMetadata(id: string): MWMediaProviderMetadata {
const provider = mediaProvidersUnchecked.find((v) => v.id === id);
if (!provider) {
return {
exists: false,
type: [],
enabled: false,
id,
};
}
return {
exists: true,
type: provider.type,
enabled: provider.enabled,
id,
provider,
};
}
/*
** get episode and season from media
*/
export function getEpisodeFromMedia(
media: MWMedia
): { season: MWMediaSeason; episode: MWMediaEpisode } | null {
if (
media.seasonId === undefined ||
media.episodeId === undefined ||
media.seriesData === undefined
) {
return null;
}
const season = media.seriesData.seasons.find((v) => v.id === media.seasonId);
if (!season) return null;
const episode = season?.episodes.find((v) => v.id === media.episodeId);
if (!episode) return null;
return {
season,
episode,
};
}

View File

@@ -0,0 +1,9 @@
import { theFlixScraper } from "providers/list/theflix";
import { MWWrappedMediaProvider, WrapProvider } from "providers/wrapper";
export const mediaProvidersUnchecked: MWWrappedMediaProvider[] = [
WrapProvider(theFlixScraper),
];
export const mediaProviders: MWWrappedMediaProvider[] =
mediaProvidersUnchecked.filter((v) => v.enabled);

View File

@@ -0,0 +1,101 @@
import Fuse from "fuse.js";
import {
MWMassProviderOutput,
MWMedia,
MWQuery,
convertMediaToPortable,
} from "providers";
import { SimpleCache } from "utils/cache";
import { GetProvidersForType } from "./helpers";
import contentCache from "./contentCache";
// cache
const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>();
resultCache.setCompare(
(a, b) => a.searchQuery === b.searchQuery && a.type === b.type
);
resultCache.initialize();
/*
** actually call all providers with the search query
*/
async function callProviders(query: MWQuery): Promise<MWMassProviderOutput> {
const allQueries = GetProvidersForType(query.type).map<
Promise<{ media: MWMedia[]; success: boolean; id: string }>
>(async (provider) => {
try {
return {
media: await provider.searchForMedia(query),
success: true,
id: provider.id,
};
} catch (err) {
console.error(`Failed running provider ${provider.id}`, err, query);
return {
media: [],
success: false,
id: provider.id,
};
}
});
const allResults = await Promise.all(allQueries);
const providerResults = allResults.map((provider) => ({
success: provider.success,
id: provider.id,
}));
const output: MWMassProviderOutput = {
results: allResults.flatMap((results) => results.media),
providers: providerResults,
stats: {
total: providerResults.length,
failed: providerResults.filter((v) => !v.success).length,
succeeded: providerResults.filter((v) => v.success).length,
},
};
// save in cache if all successfull
if (output.stats.failed === 0) {
resultCache.set(query, output, 60 * 60); // cache for an hour
}
output.results.forEach((result: MWMedia) => {
contentCache.set(convertMediaToPortable(result), result, 60 * 60);
});
return output;
}
/*
** sort results based on query
*/
function sortResults(
query: MWQuery,
providerResults: MWMassProviderOutput
): MWMassProviderOutput {
const results: MWMassProviderOutput = { ...providerResults };
const fuse = new Fuse(results.results, { threshold: 0.3, keys: ["title"] });
results.results = fuse.search(query.searchQuery).map((v) => v.item);
return results;
}
/*
** Call search on all providers that matches query type
*/
export async function SearchProviders(
inputQuery: MWQuery
): Promise<MWMassProviderOutput> {
// input normalisation
const query = { ...inputQuery };
query.searchQuery = query.searchQuery.toLowerCase().trim();
// consult cache first
let output = resultCache.get(query);
if (!output) output = await callProviders(query);
// sort results
output = sortResults(query, output);
if (output.stats.total === output.stats.failed)
throw new Error("All Scrapers failed");
return output;
}

View File

@@ -0,0 +1,37 @@
import { SimpleCache } from "utils/cache";
import { MWPortableMedia } from "providers";
import { MWMediaSeasons } from "providers/types";
import { getProviderFromId } from "./helpers";
// cache
const seasonCache = new SimpleCache<MWPortableMedia, MWMediaSeasons>();
seasonCache.setCompare(
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId
);
seasonCache.initialize();
/*
** get season data from a (portable) media object, seasons and episodes will be sorted
*/
export async function getSeasonDataFromMedia(
media: MWPortableMedia
): Promise<MWMediaSeasons> {
const provider = getProviderFromId(media.providerId);
if (!provider) {
return {
seasons: [],
};
}
if (seasonCache.has(media)) {
return seasonCache.get(media) as MWMediaSeasons;
}
const seasonData = await provider.getSeasonDataFromMedia(media);
seasonData.seasons.sort((a, b) => a.sort - b.sort);
seasonData.seasons.forEach((s) => s.episodes.sort((a, b) => a.sort - b.sort));
// cache it
seasonCache.set(media, seasonData, 60 * 60); // cache it for an hour
return seasonData;
}

91
src/providers/types.ts Normal file
View File

@@ -0,0 +1,91 @@
export enum MWMediaType {
MOVIE = "movie",
SERIES = "series",
ANIME = "anime",
}
export interface MWPortableMedia {
mediaId: string;
mediaType: MWMediaType;
providerId: string;
seasonId?: string;
episodeId?: string;
}
export type MWMediaStreamType = "m3u8" | "mp4";
export interface MWMediaCaption {
id: string;
url: string;
label: string;
}
export interface MWMediaStream {
url: string;
type: MWMediaStreamType;
captions: MWMediaCaption[];
}
export interface MWMediaMeta extends MWPortableMedia {
title: string;
year: string;
seasonCount?: number;
}
export interface MWMediaEpisode {
sort: number;
id: string;
title: string;
}
export interface MWMediaSeason {
sort: number;
id: string;
title?: string;
type: "season" | "special";
episodes: MWMediaEpisode[];
}
export interface MWMediaSeasons {
seasons: MWMediaSeason[];
}
export interface MWMedia extends MWMediaMeta {
seriesData?: MWMediaSeasons;
}
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
export interface MWQuery {
searchQuery: string;
type: MWMediaType;
}
export interface MWMediaProvider {
id: string; // id of provider, must be unique
enabled: boolean;
type: MWMediaType[];
displayName: string;
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
getSeasonDataFromMedia(media: MWPortableMedia): Promise<MWMediaSeasons>;
}
export interface MWMediaProviderMetadata {
exists: boolean;
id?: string;
enabled: boolean;
type: MWMediaType[];
provider?: MWMediaProvider;
}
export interface MWMassProviderOutput {
providers: {
id: string;
success: boolean;
}[];
results: MWMedia[];
stats: {
total: number;
failed: number;
succeeded: number;
};
}

48
src/providers/wrapper.ts Normal file
View File

@@ -0,0 +1,48 @@
import contentCache from "./methods/contentCache";
import {
MWMedia,
MWMediaProvider,
MWMediaStream,
MWPortableMedia,
MWQuery,
} from "./types";
export interface MWWrappedMediaProvider extends MWMediaProvider {
getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia>;
searchForMedia(query: MWQuery): Promise<MWMedia[]>;
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
}
export function WrapProvider(
provider: MWMediaProvider
): MWWrappedMediaProvider {
return {
...provider,
async getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia> {
// consult cache first
const output = contentCache.get(media);
if (output) {
output.seasonId = media.seasonId;
output.episodeId = media.episodeId;
return output;
}
const mediaObject = {
...(await provider.getMediaFromPortable(media)),
providerId: provider.id,
mediaType: media.mediaType,
};
contentCache.set(media, mediaObject, 60 * 60);
return mediaObject;
},
async searchForMedia(query: MWQuery): Promise<MWMedia[]> {
return (await provider.searchForMedia(query)).map<MWMedia>((m) => ({
...m,
providerId: provider.id,
mediaType: query.type,
}));
},
};
}

1
src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,119 @@
import { getProviderMetadata, MWMediaMeta } from "providers";
import {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { BookmarkStore } from "./store";
interface BookmarkStoreData {
bookmarks: MWMediaMeta[];
}
interface BookmarkStoreDataWrapper {
setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void;
getFilteredBookmarks(): MWMediaMeta[];
bookmarkStore: BookmarkStoreData;
}
const BookmarkedContext = createContext<BookmarkStoreDataWrapper>({
setItemBookmark: () => {},
getFilteredBookmarks: () => [],
bookmarkStore: {
bookmarks: [],
},
});
function getBookmarkIndexFromMedia(
bookmarks: MWMediaMeta[],
media: MWMediaMeta
): number {
const a = bookmarks.findIndex(
(v) =>
v.mediaId === media.mediaId &&
v.providerId === media.providerId &&
v.episodeId === media.episodeId &&
v.seasonId === media.seasonId
);
return a;
}
export function BookmarkContextProvider(props: { children: ReactNode }) {
const bookmarkLocalstorage = BookmarkStore.get();
const [bookmarkStorage, setBookmarkStore] = useState<BookmarkStoreData>(
bookmarkLocalstorage as BookmarkStoreData
);
const setBookmarked = useCallback(
(data: any) => {
setBookmarkStore((old) => {
const old2 = JSON.parse(JSON.stringify(old));
let newData = data;
if (data.constructor === Function) {
newData = data(old2);
}
bookmarkLocalstorage.save(newData);
return newData;
});
},
[bookmarkLocalstorage, setBookmarkStore]
);
const contextValue = useMemo(
() => ({
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) {
setBookmarked((data: BookmarkStoreData) => {
if (bookmarked) {
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
if (itemIndex === -1) {
const item = {
mediaId: media.mediaId,
mediaType: media.mediaType,
providerId: media.providerId,
title: media.title,
year: media.year,
episodeId: media.episodeId,
seasonId: media.seasonId,
};
data.bookmarks.push(item);
}
} else {
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
if (itemIndex !== -1) {
data.bookmarks.splice(itemIndex);
}
}
return data;
});
},
getFilteredBookmarks() {
return bookmarkStorage.bookmarks.filter(
(bookmark) => getProviderMetadata(bookmark.providerId)?.enabled
);
},
bookmarkStore: bookmarkStorage,
}),
[bookmarkStorage, setBookmarked]
);
return (
<BookmarkedContext.Provider value={contextValue}>
{props.children}
</BookmarkedContext.Provider>
);
}
export function useBookmarkContext() {
return useContext(BookmarkedContext);
}
export function getIfBookmarkedFromPortable(
bookmarks: MWMediaMeta[],
media: MWMediaMeta
): boolean {
const bookmarked = getBookmarkIndexFromMedia(bookmarks, media);
return bookmarked !== -1;
}

View File

@@ -0,0 +1 @@
export * from "./context";

View File

@@ -0,0 +1,45 @@
import { versionedStoreBuilder } from 'utils/storage';
/*
version 0
{
[{scraperid}]: {
movie: {
[{movie-id}]: {
full: {
currentlyAt: number,
totalDuration: number,
updatedAt: number, // unix timestamp in ms
meta: FullMetaObject, // no idea whats in here
}
}
},
show: {
[{show-id}]: {
[{season}-{episode}]: {
currentlyAt: number,
totalDuration: number,
updatedAt: number, // unix timestamp in ms
show: {
episode: string,
season: string,
},
meta: FullMetaObject, // no idea whats in here
}
}
}
}
}
*/
export const BookmarkStore = versionedStoreBuilder()
.setKey('mw-bookmarks')
.addVersion({
version: 0,
create() {
return {
bookmarks: []
}
}
})
.build()

View File

@@ -0,0 +1,153 @@
import { MWMediaMeta, getProviderMetadata, MWMediaType } from "providers";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import { VideoProgressStore } from "./store";
interface WatchedStoreItem extends MWMediaMeta {
progress: number;
percentage: number;
}
interface WatchedStoreData {
items: WatchedStoreItem[];
}
interface WatchedStoreDataWrapper {
updateProgress(media: MWMediaMeta, progress: number, total: number): void;
getFilteredWatched(): WatchedStoreItem[];
watched: WatchedStoreData;
}
export function getWatchedFromPortable(
items: WatchedStoreItem[],
media: MWMediaMeta
): WatchedStoreItem | undefined {
return items.find(
(v) =>
v.mediaId === media.mediaId &&
v.providerId === media.providerId &&
v.episodeId === media.episodeId &&
v.seasonId === media.seasonId
);
}
const WatchedContext = createContext<WatchedStoreDataWrapper>({
updateProgress: () => {},
getFilteredWatched: () => [],
watched: {
items: [],
},
});
WatchedContext.displayName = "WatchedContext";
export function WatchedContextProvider(props: { children: ReactNode }) {
const watchedLocalstorage = VideoProgressStore.get();
const [watched, setWatchedReal] = useState<WatchedStoreData>(
watchedLocalstorage as WatchedStoreData
);
const setWatched = useCallback(
(data: any) => {
setWatchedReal((old) => {
let newData = data;
if (data.constructor === Function) {
newData = data(old);
}
watchedLocalstorage.save(newData);
return newData;
});
},
[setWatchedReal, watchedLocalstorage]
);
const contextValue = useMemo(
() => ({
updateProgress(
media: MWMediaMeta,
progress: number,
total: number
): void {
setWatched((data: WatchedStoreData) => {
let item = getWatchedFromPortable(data.items, media);
if (!item) {
item = {
mediaId: media.mediaId,
mediaType: media.mediaType,
providerId: media.providerId,
title: media.title,
year: media.year,
percentage: 0,
progress: 0,
episodeId: media.episodeId,
seasonId: media.seasonId,
};
data.items.push(item);
}
// update actual item
item.progress = progress;
item.percentage = Math.round((progress / total) * 100);
return data;
});
},
getFilteredWatched() {
// remove disabled providers
let filtered = watched.items.filter(
(item) => getProviderMetadata(item.providerId)?.enabled
);
// get highest episode number for every anime/season
const highestEpisode: Record<string, [number, number]> = {};
const highestWatchedItem: Record<string, WatchedStoreItem> = {};
filtered = filtered.filter((item) => {
if (
[MWMediaType.ANIME, MWMediaType.SERIES].includes(item.mediaType)
) {
const key = `${item.mediaType}-${item.mediaId}`;
const current: [number, number] = [
item.episodeId ? parseInt(item.episodeId, 10) : -1,
item.seasonId ? parseInt(item.seasonId, 10) : -1,
];
let existing = highestEpisode[key];
if (!existing) {
existing = current;
highestEpisode[key] = current;
highestWatchedItem[key] = item;
}
if (
current[0] > existing[0] ||
(current[0] === existing[0] && current[1] > existing[1])
) {
highestEpisode[key] = current;
highestWatchedItem[key] = item;
}
return false;
}
return true;
});
return [...filtered, ...Object.values(highestWatchedItem)];
},
watched,
}),
[watched, setWatched]
);
return (
<WatchedContext.Provider value={contextValue}>
{props.children}
</WatchedContext.Provider>
);
}
export function useWatchedContext() {
return useContext(WatchedContext);
}

View File

@@ -0,0 +1 @@
export * from "./context";

View File

@@ -0,0 +1,51 @@
import { versionedStoreBuilder } from "utils/storage";
/*
version 0
{
[{scraperid}]: {
movie: {
[{movie-id}]: {
full: {
currentlyAt: number,
totalDuration: number,
updatedAt: number, // unix timestamp in ms
meta: FullMetaObject, // no idea whats in here
}
}
},
show: {
[{show-id}]: {
[{season}-{episode}]: {
currentlyAt: number,
totalDuration: number,
updatedAt: number, // unix timestamp in ms
show: {
episode: string,
season: string,
},
meta: FullMetaObject, // no idea whats in here
}
}
}
}
}
*/
export const VideoProgressStore = versionedStoreBuilder()
.setKey("video-progress")
.addVersion({
version: 0,
})
.addVersion({
version: 1,
migrate(data: any) {
// TODO migration
},
create() {
return {
items: [],
};
},
})
.build();

99
src/utils/cache.ts Normal file
View File

@@ -0,0 +1,99 @@
export class SimpleCache<Key, Value> {
protected readonly INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
protected _interval: NodeJS.Timer | null = null;
protected _compare: ((a: Key, b: Key) => boolean) | null = null;
protected _storage: { key: Key; value: Value; expiry: Date }[] = [];
/*
** initialize store, will start the interval
*/
public initialize(): void {
if (this._interval) throw new Error("cache is already initialized");
this._interval = setInterval(() => {
const now = new Date();
this._storage.filter((val) => {
if (val.expiry < now) return false; // remove if expiry date is in the past
return true;
});
}, this.INTERVAL_MS);
}
/*
** destroy cache instance, its not safe to use the instance after calling this
*/
public destroy(): void {
if (this._interval)
clearInterval(this._interval);
this.clear();
}
/*
** Set compare function, function must return true if A & B are equal
*/
public setCompare(compare: (a: Key, b: Key) => boolean): void {
this._compare = compare;
}
/*
** check if cache contains the item
*/
public has(key: Key): boolean {
return !!this.get(key);
}
/*
** get item from cache
*/
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;
return foundValue.value;
}
/*
** set item from cache, if it already exists, it will overwrite
*/
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));
// overwrite old value
if (foundValue) {
foundValue.key = key;
foundValue.value = value;
foundValue.expiry = expiry;
return;
}
// add new value to storage
this._storage.push({
key,
value,
expiry,
})
}
/*
** remove item from cache
*/
public remove(key: Key): void {
if (!this._compare) throw new Error("Compare function not set");
this._storage.filter((val) => {
if (this._compare && this._compare(val.key, key)) return false; // remove if compare is success
return true;
});
}
/*
** clear entire cache storage
*/
public clear(): void {
this._storage = [];
}
}

232
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,232 @@
// TODO make type and react safe!!
/*
it needs to be react-ified by having a save function not on the instance itself.
also type safety is important, this is all spaghetti with "any" everywhere
*/
function buildStoreObject(d: any) {
const data: any = {
versions: d.versions,
currentVersion: d.maxVersion,
id: d.storageString,
};
function update(this: any, obj2: any) {
let obj = obj2;
if (!obj) throw new Error("object to update is not an object");
// repeat until object fully updated
if (obj["--version"] === undefined) obj["--version"] = 0;
while (obj["--version"] !== this.currentVersion) {
// get version
let version: any = obj["--version"] || 0;
if (version.constructor !== Number || version < 0) version = -42;
// invalid on purpose so it will reset
else {
version = ((version as number) + 1).toString();
}
// check if version exists
if (!this.versions[version]) {
console.error(
`Version not found for storage item in store ${this.id}, resetting`
);
obj = null;
break;
}
// update object
obj = this.versions[version].update(obj);
}
// if resulting obj is null, use latest version as init object
if (obj === null) {
console.error(
`Storage item for store ${this.id} has been reset due to faulty updates`
);
return this.versions[this.currentVersion.toString()].init();
}
// updates succesful, return
return obj;
}
function get(this: any) {
// get from storage api
const store = this;
let gottenData: any = localStorage.getItem(this.id);
// parse json if item exists
if (gottenData) {
try {
gottenData = JSON.parse(gottenData);
if (!gottenData.constructor) {
console.error(
`Storage item for store ${this.id} has not constructor`
);
throw new Error("storage item has no constructor");
}
if (gottenData.constructor !== Object) {
console.error(`Storage item for store ${this.id} is not an object`);
throw new Error("storage item is not an object");
}
} catch (_) {
// if errored, set to null so it generates new one, see below
console.error(`Failed to parse storage item for store ${this.id}`);
gottenData = null;
}
}
// if item doesnt exist, generate from version init
if (!gottenData) {
gottenData = this.versions[this.currentVersion.toString()].init();
}
// update the data if needed
gottenData = this.update(gottenData);
// add a save object to return value
gottenData.save = function save(newData: any) {
const dataToStore = newData || gottenData;
localStorage.setItem(store.id, JSON.stringify(dataToStore));
};
// add instance helpers
Object.entries(d.instanceHelpers).forEach(([name, helper]: any) => {
if (gottenData[name] !== undefined)
throw new Error(
`helper name: ${name} on instance of store ${this.id} is reserved`
);
gottenData[name] = helper.bind(gottenData);
});
// return data
return gottenData;
}
// add functions to store
data.get = get.bind(data);
data.update = update.bind(data);
// add static helpers
Object.entries(d.staticHelpers).forEach(([name, helper]: any) => {
if (data[name] !== undefined)
throw new Error(`helper name: ${name} on store ${data.id} is reserved`);
data[name] = helper.bind({});
});
return data;
}
/*
* Builds a versioned store
*
* manages versioning of localstorage items
*/
export function versionedStoreBuilder(): any {
return {
_data: {
versionList: [],
maxVersion: 0,
versions: {},
storageString: undefined,
instanceHelpers: {},
staticHelpers: {},
},
setKey(str: string) {
this._data.storageString = str;
return this;
},
addVersion({ version, migrate, create }: any) {
// input checking
if (version < 0) throw new Error("Cannot add version below 0 in store");
if (version > 0 && !migrate)
throw new Error(
`Missing migration on version ${version} (needed for any version above 0)`
);
// update max version list
if (version > this._data.maxVersion) this._data.maxVersion = version;
// add to version list
this._data.versionList.push(version);
// register version
this._data.versions[version.toString()] = {
version, // version number
update: migrate
? (data: any) => {
// update function, and increment version
migrate(data);
data["--version"] = version; // eslint-disable-line no-param-reassign
return data;
}
: undefined,
init: create
? () => {
// return an initial object
const data = create();
data["--version"] = version;
return data;
}
: undefined,
};
return this;
},
registerHelper({ name, helper, type }: any) {
// type
let helperType: string = type;
if (!helperType) helperType = "instance";
// input checking
if (!name || name.constructor !== String) {
throw new Error("helper name is not a string");
}
if (!helper || helper.constructor !== Function) {
throw new Error("helper function is not a function");
}
if (!["instance", "static"].includes(helperType)) {
throw new Error("helper type must be either 'instance' or 'static'");
}
// register helper
if (helperType === "instance")
this._data.instanceHelpers[name as string] = helper;
else if (helperType === "static")
this._data.staticHelpers[name as string] = helper;
return this;
},
build() {
// check if version list doesnt skip versions
const versionListSorted = this._data.versionList.sort(
(a: number, b: number) => a - b
);
versionListSorted.forEach((v: any, i: number, arr: any[]) => {
if (i === 0) return;
if (v !== arr[i - 1] + 1)
throw new Error("Version list of store is not incremental");
});
// version zero must exist
if (versionListSorted[0] !== 0)
throw new Error("Version 0 doesn't exist in version list of store");
// max version must have init function
if (!this._data.versions[this._data.maxVersion.toString()].init)
throw new Error(
`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)`
);
// check storage string
if (!this._data.storageString)
throw new Error("storage key not set in store");
// build versioned store
return buildStoreObject(this._data);
},
};
}

206
src/views/MediaView.tsx Normal file
View File

@@ -0,0 +1,206 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Navigation } from "components/layout/Navigation";
import { Paper } from "components/layout/Paper";
import { LoadingSeasons, Seasons } from "components/layout/Seasons";
import { SkeletonVideoPlayer, VideoPlayer } from "components/media/VideoPlayer";
import { ArrowLink } from "components/text/ArrowLink";
import { DotList } from "components/text/DotList";
import { Title } from "components/text/Title";
import { useLoading } from "hooks/useLoading";
import { usePortableMedia } from "hooks/usePortableMedia";
import {
MWPortableMedia,
getStream,
MWMediaStream,
MWMedia,
convertPortableToMedia,
getProviderFromId,
MWMediaProvider,
MWMediaType,
} from "providers";
import { ReactElement, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import {
getIfBookmarkedFromPortable,
useBookmarkContext,
} from "state/bookmark";
import { getWatchedFromPortable, useWatchedContext } from "state/watched";
import { NotFoundChecks } from "./notfound/NotFoundChecks";
interface StyledMediaViewProps {
media: MWMedia;
stream: MWMediaStream;
}
function StyledMediaView(props: StyledMediaViewProps) {
const watchedStore = useWatchedContext();
const startAtTime: number | undefined = getWatchedFromPortable(
watchedStore.watched.items,
props.media
)?.progress;
function updateProgress(e: Event) {
if (!props.media) return;
const el: HTMLVideoElement = e.currentTarget as HTMLVideoElement;
if (el.currentTime <= 30) {
return; // Don't update stored progress if less than 30s into the video
}
watchedStore.updateProgress(props.media, el.currentTime, el.duration);
}
return (
<VideoPlayer
source={props.stream}
captions={props.stream.captions}
onProgress={(e) => updateProgress(e)}
startAt={startAtTime}
/>
);
}
interface StyledMediaFooterProps {
media: MWMedia;
provider: MWMediaProvider;
}
function StyledMediaFooter(props: StyledMediaFooterProps) {
const { setItemBookmark, getFilteredBookmarks } = useBookmarkContext();
const isBookmarked = getIfBookmarkedFromPortable(
getFilteredBookmarks(),
props.media
);
return (
<Paper className="mt-5">
<div className="flex">
<div className="flex-1">
<Title>{props.media.title}</Title>
<DotList
className="mt-3 text-sm"
content={[
props.provider.displayName,
props.media.mediaType,
props.media.year,
]}
/>
</div>
<div>
<IconPatch
icon={Icons.BOOKMARK}
active={isBookmarked}
onClick={() => setItemBookmark(props.media, !isBookmarked)}
clickable
/>
</div>
</div>
{props.media.mediaType !== MWMediaType.MOVIE ? (
<Seasons media={props.media} />
) : null}
</Paper>
);
}
function LoadingMediaFooter(props: { error?: boolean }) {
return (
<Paper className="mt-5">
<div className="flex">
<div className="flex-1">
<div className="bg-denim-500 mb-2 h-4 w-48 rounded-full" />
<div>
<span className="bg-denim-400 mr-4 inline-block h-2 w-12 rounded-full" />
<span className="bg-denim-400 mr-4 inline-block h-2 w-12 rounded-full" />
</div>
{props.error ? (
<div className="flex items-center space-x-3">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p>Your url may be invalid</p>
</div>
) : (
<LoadingSeasons />
)}
</div>
</div>
</Paper>
);
}
function MediaViewContent(props: { portable: MWPortableMedia }) {
const mediaPortable = props.portable;
const [streamUrl, setStreamUrl] = useState<MWMediaStream | undefined>();
const [media, setMedia] = useState<MWMedia | undefined>();
const [fetchMedia, loadingPortable, errorPortable] = useLoading(
(portable: MWPortableMedia) => convertPortableToMedia(portable)
);
const [fetchStream, loadingStream, errorStream] = useLoading(
(portable: MWPortableMedia) => getStream(portable)
);
useEffect(() => {
(async () => {
if (mediaPortable) {
setMedia(await fetchMedia(mediaPortable));
}
})();
}, [mediaPortable, setMedia, fetchMedia]);
useEffect(() => {
(async () => {
if (mediaPortable) {
setStreamUrl(await fetchStream(mediaPortable));
}
})();
}, [mediaPortable, setStreamUrl, fetchStream]);
let playerContent: ReactElement | null = null;
if (loadingStream) playerContent = <SkeletonVideoPlayer />;
else if (errorStream) playerContent = <SkeletonVideoPlayer error />;
else if (media && streamUrl)
playerContent = <StyledMediaView media={media} stream={streamUrl} />;
let footerContent: ReactElement | null = null;
if (loadingPortable) footerContent = <LoadingMediaFooter />;
else if (errorPortable) footerContent = <LoadingMediaFooter error />;
else if (mediaPortable && media)
footerContent = (
<StyledMediaFooter
provider={
getProviderFromId(mediaPortable.providerId) as MWMediaProvider
}
media={media}
/>
);
return (
<>
{playerContent}
{footerContent}
</>
);
}
export function MediaView() {
const mediaPortable: MWPortableMedia | undefined = usePortableMedia();
const reactHistory = useHistory();
return (
<div className="flex min-h-screen w-full">
<Navigation>
<ArrowLink
onClick={() =>
reactHistory.action !== "POP"
? reactHistory.goBack()
: reactHistory.push("/")
}
direction="left"
linkText="Go back"
/>
</Navigation>
<NotFoundChecks portable={mediaPortable}>
<div className="container mx-auto mt-40 mb-16 max-w-[1100px]">
<MediaViewContent portable={mediaPortable as MWPortableMedia} />
</div>
</NotFoundChecks>
</div>
);
}

213
src/views/SearchView.tsx Normal file
View File

@@ -0,0 +1,213 @@
import { WatchedMediaCard } from "components/media/WatchedMediaCard";
import { SearchBarInput } from "components/SearchBar";
import { MWMassProviderOutput, MWQuery, SearchProviders } from "providers";
import { useEffect, useMemo, useState } from "react";
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";
function SearchLoading() {
return <Loading className="my-24" text="Fetching your favourite shows..." />;
}
function SearchSuffix(props: {
fails: number;
total: number;
resultsSize: number;
}) {
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">
{props.fails}/{props.total} providers failed!
</p>
) : null}
{props.resultsSize > 0 ? (
<p>That&apos;s all we have!</p>
) : (
<p>We couldn&apos;t find anything!</p>
)}
</div>
) : null}
{/* Error result */}
{allFailed ? (
<div>
<p>All providers have failed!</p>
</div>
) : null}
</div>
);
}
function SearchResultsView({
searchQuery,
clear,
}: {
searchQuery: MWQuery;
clear: () => void;
}) {
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="Search results"
icon={Icons.SEARCH}
linkText="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 { 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="Bookmarks" icon={Icons.BOOKMARK}>
{bookmarks.map((v) => (
<WatchedMediaCard
key={[v.mediaId, v.providerId].join("|")}
media={v}
/>
))}
</SectionHeading>
) : null}
{watchedItems.length > 0 ? (
<SectionHeading title="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 [searching, setSearching] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [search, setSearch] = 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: "" })}
/>
);
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>Because watching legally is boring</Tagline>
<Title>What movie do you want to watch?</Title>
</div>
<SearchBarInput
onChange={setSearch}
value={search}
placeholder="What movie do you want to watch?"
/>
</div>
{/* results view */}
{resultView}
</ThinContainer>
</>
);
}

View File

@@ -0,0 +1,29 @@
import { getProviderMetadata, MWPortableMedia } from "providers";
import { ReactElement } from "react";
import { NotFoundMedia, NotFoundProvider } from "./NotFoundView";
export interface NotFoundChecksProps {
portable: MWPortableMedia | undefined;
children?: ReactElement;
}
/*
** Component that only renders children if the passed-in portable is fully correct
*/
export function NotFoundChecks(
props: NotFoundChecksProps
): ReactElement | null {
const providerMeta = props.portable
? getProviderMetadata(props.portable.providerId)
: undefined;
if (!providerMeta || !providerMeta.exists) {
return <NotFoundMedia />;
}
if (!providerMeta.enabled) {
return <NotFoundProvider />;
}
return props.children || null;
}

View File

@@ -0,0 +1,68 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Navigation } from "components/layout/Navigation";
import { ArrowLink } from "components/text/ArrowLink";
import { Title } from "components/text/Title";
import { ReactNode } from "react";
function NotFoundWrapper(props: { children?: ReactNode }) {
return (
<div className="h-screen flex-1">
<Navigation />
<div className="flex h-full flex-col items-center justify-center p-5 text-center">
{props.children}
</div>
</div>
);
}
export function NotFoundMedia() {
return (
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center">
<IconPatch
icon={Icons.EYE_SLASH}
className="text-bink-600 mb-6 text-xl"
/>
<Title>Couldn&apos;t find that media</Title>
<p className="mt-5 mb-12 max-w-sm">
We couldn&apos;t find the media you requested. Either it&apos;s been
removed or you tampered with the URL
</p>
<ArrowLink to="/" linkText="Back to home" />
</div>
);
}
export function NotFoundProvider() {
return (
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center">
<IconPatch
icon={Icons.EYE_SLASH}
className="text-bink-600 mb-6 text-xl"
/>
<Title>This provider has been disabled</Title>
<p className="mt-5 mb-12 max-w-sm">
We had issues with the provider or it was too unstable to use, so we had
to disable it.
</p>
<ArrowLink to="/" linkText="Back to home" />
</div>
);
}
export function NotFoundPage() {
return (
<NotFoundWrapper>
<IconPatch
icon={Icons.EYE_SLASH}
className="text-bink-600 mb-6 text-xl"
/>
<Title>Couldn&apos;t find that page</Title>
<p className="mt-5 mb-12 max-w-sm">
We looked everywhere: under the bins, in the closet, behind the proxy
but ultimately couldn&apos;t find the page you are looking for.
</p>
<ArrowLink to="/" linkText="Back to home" />
</NotFoundWrapper>
);
}