better dropdown + filtered continue watching + show episode num on watched card

This commit is contained in:
mrjvs 2022-03-13 14:34:32 +01:00
parent f80acd370c
commit 9f5b3eb9f6
7 changed files with 119 additions and 130 deletions

View File

@ -1,129 +1,61 @@
import { Icon, Icons } from "components/Icon"; import { Icon, Icons } from "components/Icon";
import React, { import React, { Fragment } from "react";
MouseEventHandler,
SyntheticEvent,
useEffect,
useState,
} from "react";
import { Backdrop, useBackdrop } from "components/layout/Backdrop"; import { Listbox, Transition } from "@headlessui/react";
import { ButtonControl } from "./buttons/ButtonControl";
export interface OptionItem { export interface OptionItem {
id: string; id: number;
name: string; name: string;
} }
interface DropdownProps { interface DropdownProps {
open: boolean; selectedItem: OptionItem;
setOpen: React.Dispatch<React.SetStateAction<boolean>>; setSelectedItem: (value: OptionItem) => void;
selectedItem: string;
setSelectedItem: (value: string) => void;
options: Array<OptionItem>; 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}
>
<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 Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>( export const Dropdown = React.forwardRef<HTMLDivElement, DropdownProps>(
(props: DropdownProps, ref) => { (props: DropdownProps) => (
const [setBackdrop, backdropProps, highlightedProps] = useBackdrop(); <div className="relative my-4 w-72 ">
const [delayedSelectedId, setDelayedSelectedId] = useState( <Listbox value={props.selectedItem} onChange={props.setSelectedItem}>
props.selectedItem {({ 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">
useEffect(() => { <span className="block truncate">{props.selectedItem.name}</span>
let id: NodeJS.Timeout; <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<Icon
if (props.open) { icon={Icons.CHEVRON_DOWN}
setDelayedSelectedId(props.selectedItem); className={`transform transition-transform ${
} else { open ? "rotate-180" : ""
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) ||
props.options[0];
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="min-w-[140px]"
onClick={() => props.setOpen((open) => !open)}
>
<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"
>
<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 scrollbar scrollbar-thumb-gray-900 scrollbar-track-gray-100 absolute top-0 z-10 w-full overflow-y-auto 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}
/> />
))} </span>
</div> </Listbox.Button>
</div> <Transition
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} /> as={Fragment}
</div> 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 z-10 mt-1 max-h-60 w-72 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>
)
); );

View File

@ -25,8 +25,6 @@ export function Seasons(props: SeasonsProps) {
const seasonSelected = props.media.season as number; const seasonSelected = props.media.season as number;
const episodeSelected = props.media.episode as number; const episodeSelected = props.media.episode as number;
const [dropdownOpen, setDropdownOpen] = useState(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const seasonData = await searchSeasons(props.media); const seasonData = await searchSeasons(props.media);
@ -45,6 +43,16 @@ export function Seasons(props: SeasonsProps) {
); );
} }
const options = seasons.seasons.map((season) => ({
id: season.seasonNumber,
name: `Season ${season.seasonNumber}`,
}));
const selectedItem = {
id: seasonSelected,
name: `Season ${seasonSelected}`,
};
return ( return (
<> <>
{loading ? <p>Loading...</p> : null} {loading ? <p>Loading...</p> : null}
@ -52,17 +60,12 @@ export function Seasons(props: SeasonsProps) {
{success && seasons.seasons.length ? ( {success && seasons.seasons.length ? (
<> <>
<Dropdown <Dropdown
open={dropdownOpen} selectedItem={selectedItem}
setOpen={setDropdownOpen} options={options}
selectedItem={`${seasonSelected}`} setSelectedItem={(seasonItem) =>
options={seasons.seasons.map((season) => ({
id: `${season.seasonNumber}`,
name: `Season ${season.seasonNumber}`,
}))}
setSelectedItem={(id) =>
navigateToSeasonAndEpisode( navigateToSeasonAndEpisode(
+id, seasonItem.id,
seasons.seasons[+id]?.episodes[0].episodeNumber seasons.seasons[seasonItem.id]?.episodes[0].episodeNumber
) )
} }
/> />

View File

@ -13,12 +13,14 @@ export interface MediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
watchedPercentage: number; watchedPercentage: number;
linkable?: boolean; linkable?: boolean;
series?: boolean;
} }
function MediaCardContent({ function MediaCardContent({
media, media,
linkable, linkable,
watchedPercentage, watchedPercentage,
series,
}: MediaCardProps) { }: MediaCardProps) {
const provider = getProviderFromId(media.providerId); const provider = getProviderFromId(media.providerId);
@ -49,7 +51,14 @@ function MediaCardContent({
<div className="relative flex flex-1"> <div className="relative flex flex-1">
{/* card content */} {/* card content */}
<div className="flex-1"> <div className="flex-1">
<h1 className="mb-1 font-bold text-white">{media.title}</h1> <h1 className="mb-1 font-bold text-white">
{media.title}
{series ? (
<span className="text-denim-700 ml-2 text-xs">
S{media.season} E{media.episode}
</span>
) : null}
</h1>
<DotList <DotList
className="text-xs" className="text-xs"
content={[provider.displayName, media.mediaType, media.year]} content={[provider.displayName, media.mediaType, media.year]}

View File

@ -4,6 +4,7 @@ import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {
media: MWMediaMeta; media: MWMediaMeta;
series?: boolean;
} }
export function WatchedMediaCard(props: WatchedMediaCardProps) { export function WatchedMediaCard(props: WatchedMediaCardProps) {
@ -15,6 +16,7 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
<MediaCard <MediaCard
watchedPercentage={watchedPercentage} watchedPercentage={watchedPercentage}
media={props.media} media={props.media}
series={props.series && props.media.episode !== undefined}
linkable linkable
/> />
); );

View File

@ -21,3 +21,11 @@ All these rules are because `PortableMedia` objects need to stay functional. bec
- It's used for routing, links would stop working - It's used for routing, links would stop working
- It's used for storage, continue watching and bookmarks 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

View File

@ -1,4 +1,4 @@
import { MWMediaMeta, getProviderMetadata } from "providers"; import { MWMediaMeta, getProviderMetadata, MWMediaType } from "providers";
import React, { import React, {
createContext, createContext,
ReactNode, ReactNode,
@ -98,9 +98,43 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
}); });
}, },
getFilteredWatched() { getFilteredWatched() {
return watched.items.filter( // remove disabled providers
let filtered = watched.items.filter(
(item) => getProviderMetadata(item.providerId)?.enabled (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.season ?? -1,
item.episode ?? -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,
}), }),

View File

@ -153,6 +153,7 @@ function ExtraItems() {
<WatchedMediaCard <WatchedMediaCard
key={[v.mediaId, v.providerId].join("|")} key={[v.mediaId, v.providerId].join("|")}
media={v} media={v}
series
/> />
))} ))}
</SectionHeading> </SectionHeading>