Add submenues for watchlist status

This commit is contained in:
Zoe Roux 2023-12-14 14:12:54 +01:00
parent 8b0faf8a0b
commit 10924e2410
5 changed files with 172 additions and 38 deletions

View File

@ -27,6 +27,7 @@ import {
ReactNode, ReactNode,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
import { StyleSheet, Pressable, View } from "react-native"; import { StyleSheet, Pressable, View } from "react-native";
@ -44,6 +45,8 @@ import { SvgProps } from "react-native-svg";
const MenuContext = createContext<((open: boolean) => void) | undefined>(undefined); const MenuContext = createContext<((open: boolean) => void) | undefined>(undefined);
type Optional<T, K extends keyof any> = Omit<T, K> & Partial<T>;
const Menu = <AsProps,>({ const Menu = <AsProps,>({
Trigger, Trigger,
onMenuOpen, onMenuOpen,
@ -59,20 +62,31 @@ const Menu = <AsProps,>({
onMenuClose?: () => void; onMenuClose?: () => void;
isOpen?: boolean; isOpen?: boolean;
setOpen?: (v: boolean) => void; setOpen?: (v: boolean) => void;
} & Omit<AsProps, "onPress">) => { } & Optional<AsProps, "onPress">) => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const alreadyRendered = useRef(false);
const [isOpen, setOpen] = const [isOpen, setOpen] =
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
outerOpen !== undefined && outerSetOpen ? [outerOpen, outerSetOpen] : useState(false); outerOpen !== undefined && outerSetOpen ? [outerOpen, outerSetOpen] : useState(false);
// deos the same as a useMemo but for props.
const memoRef = useRef({ onMenuOpen, onMenuClose });
memoRef.current = { onMenuOpen, onMenuClose };
useEffect(() => { useEffect(() => {
if (isOpen) onMenuOpen?.call(null); if (isOpen) memoRef.current.onMenuOpen?.();
else onMenuClose?.call(null); else if (alreadyRendered.current) memoRef.current.onMenuClose?.();
}, [isOpen, onMenuClose, onMenuOpen]); alreadyRendered.current = true;
}, [isOpen]);
return ( return (
<> <>
<Trigger onPress={() => setOpen(true)} {...(props as any)} /> <Trigger
onPress={() => {
if ("onPress" in props && typeof props.onPress === "function") props.onPress();
setOpen(true);
}}
{...(props as any)}
/>
{isOpen && ( {isOpen && (
<Portal> <Portal>
<ContrastArea mode="user"> <ContrastArea mode="user">
@ -195,4 +209,24 @@ const MenuItem = ({
}; };
Menu.Item = MenuItem; Menu.Item = MenuItem;
const Sub = <AsProps,>({
children,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
icon?: ComponentType<SvgProps>;
children?: ReactNode | ReactNode[] | null;
} & AsProps) => {
const setOpen = useContext(MenuContext);
return (
<Menu Trigger={MenuItem} onMenuClose={() => setOpen?.(false)} {...props}>
{children}
</Menu>
);
};
Menu.Sub = Sub;
export { Menu }; export { Menu };

View File

@ -66,7 +66,7 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
modal modal
open={isOpen} open={isOpen}
onOpenChange={(newOpen) => { onOpenChange={(newOpen) => {
if (setOpen) setOpen(newOpen) if (setOpen) setOpen(newOpen);
if (newOpen) onMenuOpen?.call(null); if (newOpen) onMenuOpen?.call(null);
else onMenuClose?.call(null); else onMenuClose?.call(null);
}} }}
@ -77,7 +77,7 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
<ContrastArea mode="user"> <ContrastArea mode="user">
<SwitchVariant> <SwitchVariant>
<YoshikiProvider> <YoshikiProvider>
{({ css }) => ( {({ css, theme }) => (
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content
onFocusOutside={(e) => e.stopImmediatePropagation()} onFocusOutside={(e) => e.stopImmediatePropagation()}
@ -93,6 +93,7 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
})} })}
> >
{children} {children}
<DropdownMenu.Arrow fill={theme.background} />
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
)} )}
@ -106,10 +107,10 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
const Item = forwardRef< const Item = forwardRef<
HTMLDivElement, HTMLDivElement,
ComponentProps<typeof DropdownMenu.Item> & { href?: string } ComponentProps<typeof DropdownMenu.Item> & { href?: string }
>(function _Item({ children, href, ...props }, ref) { >(function _Item({ children, href, onSelect, ...props }, ref) {
if (href) { if (href) {
return ( return (
<DropdownMenu.Item ref={ref} {...props} asChild> <DropdownMenu.Item ref={ref} onSelect={onSelect} {...props} asChild>
<Link href={href} style={{ textDecoration: "none" }}> <Link href={href} style={{ textDecoration: "none" }}>
{children} {children}
</Link> </Link>
@ -117,28 +118,22 @@ const Item = forwardRef<
); );
} }
return ( return (
<DropdownMenu.Item ref={ref} {...props}> <DropdownMenu.Item ref={ref} onSelect={onSelect} {...props}>
{children} {children}
</DropdownMenu.Item> </DropdownMenu.Item>
); );
}); });
const MenuItem = ({ const MenuItem = forwardRef<
label, HTMLDivElement,
icon, {
left, label: string;
selected, icon?: ComponentType<SvgProps>;
onSelect, left?: ReactElement;
href, disabled?: boolean;
disabled, selected?: boolean;
...props } & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })
}: { >(function MenuItem({ label, icon, left, selected, onSelect, href, disabled, ...props }, ref) {
label: string;
icon?: ComponentType<SvgProps>;
left?: ReactElement;
disabled?: boolean;
selected?: boolean;
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => {
const { css: nCss } = useNativeYoshiki(); const { css: nCss } = useNativeYoshiki();
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
@ -159,6 +154,7 @@ const MenuItem = ({
} }
`}</style> `}</style>
<Item <Item
ref={ref}
onSelect={onSelect} onSelect={onSelect}
href={href} href={href}
disabled={disabled} disabled={disabled}
@ -190,7 +186,49 @@ const MenuItem = ({
</Item> </Item>
</> </>
); );
}; });
Menu.Item = MenuItem; Menu.Item = MenuItem;
const Sub = <AsProps,>({
children,
disabled,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
icon?: ComponentType<SvgProps>;
children: ReactNode | ReactNode[] | null;
} & AsProps) => {
const { css, theme } = useYoshiki();
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger asChild disabled={disabled}>
<MenuItem disabled={disabled} {...props} onSelect={(e?: any) => e.preventDefault()} />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
onFocusOutside={(e) => e.stopImmediatePropagation()}
{...css({
bg: (theme) => theme.background,
overflow: "auto",
minWidth: "220px",
borderRadius: "8px",
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
zIndex: 2,
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
})}
>
{children}
<DropdownMenu.Arrow fill={theme.background} />
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
);
};
Menu.Sub = Sub;
export { Menu }; export { Menu };

View File

@ -23,16 +23,58 @@ import { ComponentProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg"; import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg";
import Info from "@material-symbols/svg-400/rounded/info.svg"; import Info from "@material-symbols/svg-400/rounded/info.svg";
import { WatchStatusV, queryFn, useAccount } from "@kyoo/models";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { watchListIcon } from "./watchlist-info";
export const EpisodesContext = ({ export const EpisodesContext = ({
showSlug, showSlug,
slug,
status,
...props ...props
}: { showSlug: string } & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => { }: { showSlug?: string; slug: string; status: WatchStatusV | null } & Partial<
ComponentProps<typeof Menu<typeof IconButton>>
>) => {
const account = useAccount();
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newStatus: WatchStatusV | null) =>
queryFn({
path: ["episode", slug, "watchStatus", newStatus && `?status=${newStatus}`],
method: newStatus ? "POST" : "DELETE",
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["episode", slug] }),
});
return ( return (
<Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))} {...(props as any)}> <Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))} {...(props as any)}>
<Menu.Item label={t("home.episodeMore.goToShow")} icon={Info} href={`/show/${showSlug}`} /> <Menu.Item
label={t("home.episodeMore.goToShow")}
icon={Info}
onSelect={() => console.log("tot")}
/>
{showSlug && (
<Menu.Item label={t("home.episodeMore.goToShow")} icon={Info} href={`/show/${showSlug}`} />
)}
<Menu.Sub
label={account ? t("show.watchlistEdit") : t("show.watchlistLogin")}
disabled={!account}
icon={watchListIcon(status)}
>
{Object.values(WatchStatusV).map((x) => (
<Menu.Item
key={x}
label={t(`show.watchlistMark.${x.toLowerCase()}`)}
onSelect={() => mutation.mutate(x)}
selected={x === status}
/>
))}
{status !== null && (
<Menu.Item label={t("show.watchlistMark.null")} onSelect={() => mutation.mutate(null)} />
)}
</Menu.Sub>
</Menu> </Menu>
); );
}; };

View File

@ -28,6 +28,19 @@ import BookmarkRemove from "@material-symbols/svg-400/rounded/bookmark_remove.sv
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { WatchStatusV, queryFn, useAccount } from "@kyoo/models"; import { WatchStatusV, queryFn, useAccount } from "@kyoo/models";
export const watchListIcon = (status: WatchStatusV | null) => {
switch (status) {
case null:
return BookmarkAdd;
case WatchStatusV.Completed:
return BookmarkAdded;
case WatchStatusV.Droped:
return BookmarkRemove;
default:
return Bookmark;
}
};
export const WatchListInfo = ({ export const WatchListInfo = ({
type, type,
slug, slug,
@ -84,7 +97,7 @@ export const WatchListInfo = ({
return ( return (
<Menu <Menu
Trigger={IconButton} Trigger={IconButton}
icon={status === WatchStatusV.Droped ? BookmarkRemove : Bookmark} icon={watchListIcon(status)}
{...tooltip(t("show.watchlistEdit"))} {...tooltip(t("show.watchlistEdit"))}
{...props} {...props}
> >

View File

@ -144,6 +144,7 @@ export const EpisodeBox = ({
export const EpisodeLine = ({ export const EpisodeLine = ({
slug, slug,
showSlug,
displayNumber, displayNumber,
name, name,
thumbnail, thumbnail,
@ -161,6 +162,7 @@ export const EpisodeLine = ({
}: WithLoading<{ }: WithLoading<{
id: string; id: string;
slug: string; slug: string;
showSlug: string;
displayNumber: string; displayNumber: string;
name: string | null; name: string | null;
overview: string | null; overview: string | null;
@ -263,14 +265,19 @@ export const EpisodeLine = ({
</View> </View>
<View {...css({ flexDirection: "row" })}> <View {...css({ flexDirection: "row" })}>
<Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton> <Skeleton>{isLoading || <P numberOfLines={3}>{overview}</P>}</Skeleton>
<EpisodesContext {slug && watchedStatus !== undefined && (
isOpen={moreOpened} <EpisodesContext
setOpen={setMoreOpened} slug={slug}
{...css([ showSlug={showSlug}
"more", status={watchedStatus}
Platform.OS === "web" && moreOpened && { display: "flex !important" as any }, isOpen={moreOpened}
])} setOpen={(v) => setMoreOpened(v)}
/> {...css([
"more",
Platform.OS === "web" && moreOpened && { display: "flex !important" as any },
])}
/>
)}
</View> </View>
</View> </View>
</Link> </Link>