mirror of
https://github.com/movie-web/movie-web.git
synced 2025-09-13 18:13:24 +00:00
move src back
This commit is contained in:
28
src/App.tsx
Normal file
28
src/App.tsx
Normal 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;
|
61
src/components/Dropdown.tsx
Normal file
61
src/components/Dropdown.tsx
Normal 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
49
src/components/Icon.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
67
src/components/SearchBar.tsx
Normal file
67
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
17
src/components/buttons/ButtonControl.tsx
Normal file
17
src/components/buttons/ButtonControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
129
src/components/buttons/DropdownButton.tsx
Normal file
129
src/components/buttons/DropdownButton.tsx
Normal 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>
|
||||
);
|
||||
});
|
18
src/components/buttons/IconButton.tsx
Normal file
18
src/components/buttons/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
25
src/components/buttons/IconPatch.tsx
Normal file
25
src/components/buttons/IconPatch.tsx
Normal 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>
|
||||
);
|
||||
}
|
68
src/components/layout/Backdrop.tsx
Normal file
68
src/components/layout/Backdrop.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
16
src/components/layout/BrandPill.tsx
Normal file
16
src/components/layout/BrandPill.tsx
Normal 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>
|
||||
);
|
||||
}
|
82
src/components/layout/ErrorBoundary.tsx
Normal file
82
src/components/layout/ErrorBoundary.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
}
|
22
src/components/layout/Loading.tsx
Normal file
22
src/components/layout/Loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
43
src/components/layout/Navigation.tsx
Normal file
43
src/components/layout/Navigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
src/components/layout/Paper.tsx
Normal file
14
src/components/layout/Paper.tsx
Normal 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>
|
||||
)
|
||||
}
|
119
src/components/layout/Seasons.tsx
Normal file
119
src/components/layout/Seasons.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
37
src/components/layout/SectionHeading.tsx
Normal file
37
src/components/layout/SectionHeading.tsx
Normal 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>
|
||||
);
|
||||
}
|
16
src/components/layout/ThinContainer.tsx
Normal file
16
src/components/layout/ThinContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
25
src/components/media/EpisodeButton.tsx
Normal file
25
src/components/media/EpisodeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
97
src/components/media/MediaCard.tsx
Normal file
97
src/components/media/MediaCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
84
src/components/media/VideoPlayer.tsx
Normal file
84
src/components/media/VideoPlayer.tsx
Normal 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'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>
|
||||
</>
|
||||
);
|
||||
}
|
25
src/components/media/WatchedEpisodeButton.tsx
Normal file
25
src/components/media/WatchedEpisodeButton.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
23
src/components/media/WatchedMediaCard.tsx
Normal file
23
src/components/media/WatchedMediaCard.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
39
src/components/text-inputs/TextInputControl.tsx
Normal file
39
src/components/text-inputs/TextInputControl.tsx
Normal 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;
|
||||
}
|
53
src/components/text/ArrowLink.tsx
Normal file
53
src/components/text/ArrowLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
src/components/text/DotList.tsx
Normal file
19
src/components/text/DotList.tsx
Normal 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]">●</span>
|
||||
) : null}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
42
src/components/text/Link.tsx
Normal file
42
src/components/text/Link.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
src/components/text/Tagline.tsx
Normal file
7
src/components/text/Tagline.tsx
Normal 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>;
|
||||
}
|
7
src/components/text/Title.tsx
Normal file
7
src/components/text/Title.tsx
Normal 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
20
src/hooks/useDebounce.ts
Normal 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
17
src/hooks/useFade.css
Normal 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
27
src/hooks/useFade.ts
Normal 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
47
src/hooks/useLoading.ts
Normal 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];
|
||||
}
|
30
src/hooks/usePortableMedia.ts
Normal file
30
src/hooks/usePortableMedia.ts
Normal 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;
|
||||
}
|
34
src/hooks/useSearchQuery.ts
Normal file
34
src/hooks/useSearchQuery.ts
Normal 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
16
src/index.css
Normal 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
17
src/index.tsx
Normal 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
3
src/mw_constants.ts
Normal 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
31
src/providers/README.md
Normal 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
42
src/providers/index.ts
Normal 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);
|
||||
}
|
113
src/providers/list/theflix/index.ts
Normal file
113
src/providers/list/theflix/index.ts
Normal 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,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
36
src/providers/list/theflix/portableToMedia.ts
Normal file
36
src/providers/list/theflix/portableToMedia.ts
Normal 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;
|
||||
}
|
48
src/providers/list/theflix/search.ts
Normal file
48
src/providers/list/theflix/search.ts
Normal 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,
|
||||
};
|
||||
}
|
9
src/providers/methods/contentCache.ts
Normal file
9
src/providers/methods/contentCache.ts
Normal 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;
|
65
src/providers/methods/helpers.ts
Normal file
65
src/providers/methods/helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
9
src/providers/methods/providers.ts
Normal file
9
src/providers/methods/providers.ts
Normal 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);
|
101
src/providers/methods/search.ts
Normal file
101
src/providers/methods/search.ts
Normal 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;
|
||||
}
|
37
src/providers/methods/seasons.ts
Normal file
37
src/providers/methods/seasons.ts
Normal 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
91
src/providers/types.ts
Normal 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
48
src/providers/wrapper.ts
Normal 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
1
src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
119
src/state/bookmark/context.tsx
Normal file
119
src/state/bookmark/context.tsx
Normal 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;
|
||||
}
|
1
src/state/bookmark/index.ts
Normal file
1
src/state/bookmark/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./context";
|
45
src/state/bookmark/store.ts
Normal file
45
src/state/bookmark/store.ts
Normal 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()
|
153
src/state/watched/context.tsx
Normal file
153
src/state/watched/context.tsx
Normal 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);
|
||||
}
|
1
src/state/watched/index.ts
Normal file
1
src/state/watched/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./context";
|
51
src/state/watched/store.ts
Normal file
51
src/state/watched/store.ts
Normal 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
99
src/utils/cache.ts
Normal 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
232
src/utils/storage.ts
Normal 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
206
src/views/MediaView.tsx
Normal 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
213
src/views/SearchView.tsx
Normal 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's all we have!</p>
|
||||
) : (
|
||||
<p>We couldn'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>
|
||||
</>
|
||||
);
|
||||
}
|
29
src/views/notfound/NotFoundChecks.tsx
Normal file
29
src/views/notfound/NotFoundChecks.tsx
Normal 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;
|
||||
}
|
68
src/views/notfound/NotFoundView.tsx
Normal file
68
src/views/notfound/NotFoundView.tsx
Normal 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't find that media</Title>
|
||||
<p className="mt-5 mb-12 max-w-sm">
|
||||
We couldn't find the media you requested. Either it'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'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't find the page you are looking for.
|
||||
</p>
|
||||
<ArrowLink to="/" linkText="Back to home" />
|
||||
</NotFoundWrapper>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user