mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-29 17:56:06 +00:00
better dropdown + filtered continue watching + show episode num on watched card
This commit is contained in:
parent
f80acd370c
commit
9f5b3eb9f6
|
@ -1,129 +1,61 @@
|
|||
import { Icon, Icons } from "components/Icon";
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { Backdrop, useBackdrop } from "components/layout/Backdrop";
|
||||
import { ButtonControl } from "./buttons/ButtonControl";
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
|
||||
export interface OptionItem {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
selectedItem: string;
|
||||
setSelectedItem: (value: string) => void;
|
||||
selectedItem: OptionItem;
|
||||
setSelectedItem: (value: OptionItem) => 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}
|
||||
>
|
||||
<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>(
|
||||
(props: DropdownProps, 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) ||
|
||||
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}
|
||||
(props: DropdownProps) => (
|
||||
<div className="relative my-4 w-72 ">
|
||||
<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" : ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Backdrop onClick={() => props.setOpen(false)} {...backdropProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</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 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>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -25,8 +25,6 @@ export function Seasons(props: SeasonsProps) {
|
|||
const seasonSelected = props.media.season as number;
|
||||
const episodeSelected = props.media.episode as number;
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
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 (
|
||||
<>
|
||||
{loading ? <p>Loading...</p> : null}
|
||||
|
@ -52,17 +60,12 @@ export function Seasons(props: SeasonsProps) {
|
|||
{success && seasons.seasons.length ? (
|
||||
<>
|
||||
<Dropdown
|
||||
open={dropdownOpen}
|
||||
setOpen={setDropdownOpen}
|
||||
selectedItem={`${seasonSelected}`}
|
||||
options={seasons.seasons.map((season) => ({
|
||||
id: `${season.seasonNumber}`,
|
||||
name: `Season ${season.seasonNumber}`,
|
||||
}))}
|
||||
setSelectedItem={(id) =>
|
||||
selectedItem={selectedItem}
|
||||
options={options}
|
||||
setSelectedItem={(seasonItem) =>
|
||||
navigateToSeasonAndEpisode(
|
||||
+id,
|
||||
seasons.seasons[+id]?.episodes[0].episodeNumber
|
||||
seasonItem.id,
|
||||
seasons.seasons[seasonItem.id]?.episodes[0].episodeNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -13,12 +13,14 @@ export interface MediaCardProps {
|
|||
media: MWMediaMeta;
|
||||
watchedPercentage: number;
|
||||
linkable?: boolean;
|
||||
series?: boolean;
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
media,
|
||||
linkable,
|
||||
watchedPercentage,
|
||||
series,
|
||||
}: MediaCardProps) {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
|
||||
|
@ -49,7 +51,14 @@ function MediaCardContent({
|
|||
<div className="relative flex flex-1">
|
||||
{/* card content */}
|
||||
<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
|
||||
className="text-xs"
|
||||
content={[provider.displayName, media.mediaType, media.year]}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { MediaCard } from "./MediaCard";
|
|||
|
||||
export interface WatchedMediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
series?: boolean;
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
|
@ -15,6 +16,7 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
|||
<MediaCard
|
||||
watchedPercentage={watchedPercentage}
|
||||
media={props.media}
|
||||
series={props.series && props.media.episode !== undefined}
|
||||
linkable
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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 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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MWMediaMeta, getProviderMetadata } from "providers";
|
||||
import { MWMediaMeta, getProviderMetadata, MWMediaType } from "providers";
|
||||
import React, {
|
||||
createContext,
|
||||
ReactNode,
|
||||
|
@ -98,9 +98,43 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||
});
|
||||
},
|
||||
getFilteredWatched() {
|
||||
return watched.items.filter(
|
||||
// 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.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,
|
||||
}),
|
||||
|
|
|
@ -153,6 +153,7 @@ function ExtraItems() {
|
|||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
series
|
||||
/>
|
||||
))}
|
||||
</SectionHeading>
|
||||
|
|
Loading…
Reference in a new issue