mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-11 01:35:08 +01: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 { 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>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -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]}
|
||||||
|
@ -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
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user