Add more menus everywhere

This commit is contained in:
Zoe Roux 2023-12-14 16:49:07 +01:00
parent c67a11b8b6
commit e8bd663ad7
11 changed files with 204 additions and 44 deletions

View File

@ -67,6 +67,10 @@ export const BaseEpisodeP = withImages(
*/ */
hls: z.string().transform(imageFn), hls: z.string().transform(imageFn),
}), }),
/**
* The id of the show containing this episode
*/
showId: z.string(),
}), }),
"episodes", "episodes",
) )

View File

@ -21,6 +21,10 @@
import { Platform } from "react-native"; import { Platform } from "react-native";
import { px } from "yoshiki/native"; import { px } from "yoshiki/native";
export const important = <T,>(value: T): T => {
return `${value} !important` as T;
}
export const ts = (spacing: number) => { export const ts = (spacing: number) => {
return px(spacing * 8); return px(spacing * 8);
}; };

View File

@ -19,11 +19,13 @@
*/ */
import { KyooImage, WatchStatusV } from "@kyoo/models"; import { KyooImage, WatchStatusV } from "@kyoo/models";
import { Link, Skeleton, ts, focusReset, P, SubP, PosterBackground, Icon } from "@kyoo/primitives"; import { Link, Skeleton, ts, focusReset, P, SubP, PosterBackground, Icon, important } from "@kyoo/primitives";
import { ImageStyle, View } from "react-native"; import { ImageStyle, Platform, View } from "react-native";
import { max, percent, px, rem, Stylable, Theme, useYoshiki } from "yoshiki/native"; import { max, percent, px, rem, Stylable, Theme, useYoshiki } from "yoshiki/native";
import { Layout, WithLoading } from "../fetch"; import { Layout, WithLoading } from "../fetch";
import Done from "@material-symbols/svg-400/rounded/done-fill.svg"; import Done from "@material-symbols/svg-400/rounded/done-fill.svg";
import { ItemContext } from "../components/context-menus";
import { useState } from "react";
export const ItemWatchStatus = ({ export const ItemWatchStatus = ({
watchStatus, watchStatus,
@ -96,6 +98,7 @@ export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
export const ItemGrid = ({ export const ItemGrid = ({
href, href,
slug,
name, name,
type, type,
subtitle, subtitle,
@ -107,6 +110,7 @@ export const ItemGrid = ({
...props ...props
}: WithLoading<{ }: WithLoading<{
href: string; href: string;
slug: string;
name: string; name: string;
subtitle?: string; subtitle?: string;
poster?: KyooImage | null; poster?: KyooImage | null;
@ -116,11 +120,13 @@ export const ItemGrid = ({
unseenEpisodesCount: number | null; unseenEpisodesCount: number | null;
}> & }> &
Stylable<"text">) => { Stylable<"text">) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("grid"); const { css } = useYoshiki("grid");
return ( return (
<Link <Link
href={href} href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css( {...css(
{ {
flexDirection: "column", flexDirection: "column",
@ -132,6 +138,9 @@ export const ItemGrid = ({
borderWidth: ts(0.5), borderWidth: ts(0.5),
borderStyle: "solid", borderStyle: "solid",
}, },
more: {
display: "none",
},
}, },
fover: { fover: {
self: focusReset, self: focusReset,
@ -141,6 +150,9 @@ export const ItemGrid = ({
title: { title: {
textDecorationLine: "underline", textDecorationLine: "underline",
}, },
more: {
display: "flex",
},
}, },
}, },
props, props,
@ -156,6 +168,25 @@ export const ItemGrid = ({
> >
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} /> <ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
{type === "movie" && watchPercent && <ItemProgress watchPercent={watchPercent} />} {type === "movie" && watchPercent && <ItemProgress watchPercent={watchPercent} />}
{slug && watchStatus !== undefined && type && type !== "collection" && (
<ItemContext
type={type}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
{
position: "absolute",
top: 0,
right: 0,
bg: (theme) => theme.dark.background,
},
"more",
Platform.OS === "web" && moreOpened && { display: important("flex") },
])}
/>
)}
</PosterBackground> </PosterBackground>
<Skeleton> <Skeleton>
{isLoading || ( {isLoading || (

View File

@ -45,6 +45,7 @@ export const itemMap = (
return { return {
isLoading: item.isLoading, isLoading: item.isLoading,
slug: item.slug,
name: item.name, name: item.name,
subtitle: item.kind !== ItemKind.Collection ? getDisplayDate(item) : undefined, subtitle: item.kind !== ItemKind.Collection ? getDisplayDate(item) : undefined,
href: item.href, href: item.href,

View File

@ -28,15 +28,19 @@ import {
Heading, Heading,
PosterBackground, PosterBackground,
imageBorderRadius, imageBorderRadius,
important,
} from "@kyoo/primitives"; } from "@kyoo/primitives";
import { useState } from "react"; import { useState } from "react";
import { View } from "react-native"; import { Platform, View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native"; import { percent, px, rem, useYoshiki } from "yoshiki/native";
import { Layout, WithLoading } from "../fetch"; import { Layout, WithLoading } from "../fetch";
import { ItemWatchStatus } from "./grid"; import { ItemWatchStatus } from "./grid";
import { ItemContext } from "../components/context-menus";
export const ItemList = ({ export const ItemList = ({
href, href,
slug,
type,
name, name,
subtitle, subtitle,
thumbnail, thumbnail,
@ -47,6 +51,8 @@ export const ItemList = ({
...props ...props
}: WithLoading<{ }: WithLoading<{
href: string; href: string;
slug: string;
type: "movie" | "show" | "collection";
name: string; name: string;
subtitle?: string; subtitle?: string;
poster?: KyooImage | null; poster?: KyooImage | null;
@ -55,7 +61,7 @@ export const ItemList = ({
unseenEpisodesCount: number | null; unseenEpisodesCount: number | null;
}>) => { }>) => {
const { css } = useYoshiki(); const { css } = useYoshiki();
const [isHovered, setHovered] = useState(0); const [moreOpened, setMoreOpened] = useState(false);
return ( return (
<ImageBackground <ImageBackground
@ -63,11 +69,8 @@ export const ItemList = ({
alt={name} alt={name}
quality="medium" quality="medium"
as={Link} as={Link}
href={href ?? ""} href={moreOpened ? undefined : href}
onFocus={() => setHovered((i) => i + 1)} onLongPress={() => setMoreOpened(true)}
onBlur={() => setHovered((i) => i - 1)}
onPressIn={() => setHovered((i) => i + 1)}
onPressOut={() => setHovered((i) => i - 1)}
containerStyle={{ containerStyle={{
borderRadius: px(imageBorderRadius), borderRadius: px(imageBorderRadius),
}} }}
@ -83,38 +86,78 @@ export const ItemList = ({
borderRadius: px(imageBorderRadius), borderRadius: px(imageBorderRadius),
overflow: "hidden", overflow: "hidden",
marginX: ItemList.layout.gap, marginX: ItemList.layout.gap,
child: {
more: {
opacity: 0,
},
},
fover: {
title: {
textDecorationLine: "underline",
},
more: {
opacity: 100,
},
},
}, },
props, props,
)} )}
> >
<View <View
{...css({ {...css({
flexDirection: "column",
width: { xs: "50%", lg: "30%" }, width: { xs: "50%", lg: "30%" },
})} })}
> >
<Skeleton {...css({ height: rem(2), alignSelf: "center" })}> <View
{isLoading || ( {...css({
<Heading flexDirection: "row",
{...css({ justifyContent: "center",
textAlign: "center", })}
fontSize: rem(2), >
letterSpacing: rem(0.002), <Skeleton {...css({ height: rem(2), alignSelf: "center" })}>
fontWeight: "900", {isLoading || (
textTransform: "uppercase", <Heading
textDecorationLine: isHovered ? "underline" : "none", {...css([
})} "title",
> {
{name} textAlign: "center",
</Heading> fontSize: rem(2),
letterSpacing: rem(0.002),
fontWeight: "900",
textTransform: "uppercase",
},
])}
>
{name}
</Heading>
)}
</Skeleton>
{slug && watchStatus !== undefined && type && type !== "collection" && (
<ItemContext
type={type}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
{
// I dont know why marginLeft gets overwritten by the margin: px(2) so we important
marginLeft: important(ts(2)),
bg: (theme) => theme.darkOverlay,
},
"more",
Platform.OS === "web" && moreOpened && { opacity: important(100) },
])}
/>
)} )}
</Skeleton> </View>
{(isLoading || subtitle) && ( {(isLoading || subtitle) && (
<Skeleton {...css({ width: rem(5), alignSelf: "center" })}> <Skeleton {...css({ width: rem(5), alignSelf: "center" })}>
{isLoading || ( {isLoading || (
<P <P
{...css({ {...css({
textAlign: "center", textAlign: "center",
marginRight: ts(4),
})} })}
> >
{subtitle} {subtitle}

View File

@ -160,6 +160,8 @@ export const CollectionPage: QueryPage<{ slug: string }> = ({ slug }) => {
{(x) => ( {(x) => (
<ItemDetails <ItemDetails
isLoading={x.isLoading as any} isLoading={x.isLoading as any}
slug={x.slug}
type={x.kind?.toLowerCase() as any}
name={x.name} name={x.name}
tagline={"tagline" in x ? x.tagline : null} tagline={"tagline" in x ? x.tagline : null}
overview={x.overview} overview={x.overview}

View File

@ -28,13 +28,17 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { watchListIcon } from "./watchlist-info"; import { watchListIcon } from "./watchlist-info";
export const EpisodesContext = ({ export const EpisodesContext = ({
showSlug, type = "episode",
slug, slug,
showSlug,
status, status,
...props ...props
}: { showSlug?: string | null; slug: string; status: WatchStatusV | null } & Partial< }: {
ComponentProps<typeof Menu<typeof IconButton>> type?: "show" | "movie" | "episode";
>) => { showSlug?: string | null;
slug: string;
status: WatchStatusV | null;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
const account = useAccount(); const account = useAccount();
const { t } = useTranslation(); const { t } = useTranslation();
@ -42,10 +46,10 @@ export const EpisodesContext = ({
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (newStatus: WatchStatusV | null) => mutationFn: (newStatus: WatchStatusV | null) =>
queryFn({ queryFn({
path: ["episode", slug, "watchStatus", newStatus && `?status=${newStatus}`], path: [type, slug, "watchStatus", newStatus && `?status=${newStatus}`],
method: newStatus ? "POST" : "DELETE", method: newStatus ? "POST" : "DELETE",
}), }),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["episode", slug] }), onSettled: async () => await queryClient.invalidateQueries({ queryKey: [type, slug] }),
}); });
return ( return (
@ -73,3 +77,16 @@ export const EpisodesContext = ({
</Menu> </Menu>
); );
}; };
export const ItemContext = ({
type,
slug,
status,
...props
}: {
type: "movie" | "show";
slug: string;
status: WatchStatusV | null;
} & Partial<ComponentProps<typeof Menu<typeof IconButton>>>) => {
return <EpisodesContext type={type} slug={slug} status={status} showSlug={null} {...props} />;
};

View File

@ -23,6 +23,7 @@ import {
H6, H6,
ImageBackground, ImageBackground,
ImageProps, ImageProps,
important,
Link, Link,
P, P,
Skeleton, Skeleton,
@ -58,6 +59,8 @@ export const displayRuntime = (runtime: number) => {
}; };
export const EpisodeBox = ({ export const EpisodeBox = ({
slug,
showSlug,
name, name,
overview, overview,
thumbnail, thumbnail,
@ -68,6 +71,9 @@ export const EpisodeBox = ({
...props ...props
}: Stylable & }: Stylable &
WithLoading<{ WithLoading<{
slug: string;
// if show slug is null, disable "Go to show" in the context menu
showSlug: string | null;
name: string | null; name: string | null;
overview: string | null; overview: string | null;
href: string; href: string;
@ -75,12 +81,14 @@ export const EpisodeBox = ({
watchedPercent: number | null; watchedPercent: number | null;
watchedStatus: WatchStatusV | null; watchedStatus: WatchStatusV | null;
}>) => { }>) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episodebox"); const { css } = useYoshiki("episodebox");
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Link <Link
href={href} href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css( {...css(
{ {
alignItems: "center", alignItems: "center",
@ -90,6 +98,9 @@ export const EpisodeBox = ({
borderWidth: ts(0.5), borderWidth: ts(0.5),
borderStyle: "solid", borderStyle: "solid",
}, },
more: {
display: "none",
},
}, },
fover: { fover: {
self: focusReset, self: focusReset,
@ -99,6 +110,9 @@ export const EpisodeBox = ({
title: { title: {
textDecorationLine: "underline", textDecorationLine: "underline",
}, },
more: {
display: "flex",
},
}, },
}, },
props, props,
@ -117,6 +131,25 @@ export const EpisodeBox = ({
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && ( {(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
<ItemProgress watchPercent={watchedPercent ?? 100} /> <ItemProgress watchPercent={watchedPercent ?? 100} />
)} )}
{slug && watchedStatus !== undefined && (
<EpisodesContext
slug={slug}
showSlug={showSlug}
status={watchedStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
{...css([
{
position: "absolute",
top: 0,
right: 0,
bg: (theme) => theme.darkOverlay,
},
"more",
Platform.OS === "web" && moreOpened && { display: important("flex") },
])}
/>
)}
</ImageBackground> </ImageBackground>
<Skeleton {...css({ width: percent(50) })}> <Skeleton {...css({ width: percent(50) })}>
{isLoading || ( {isLoading || (
@ -177,8 +210,7 @@ export const EpisodeLine = ({
watchedPercent: number | null; watchedPercent: number | null;
watchedStatus: WatchStatusV | null; watchedStatus: WatchStatusV | null;
href: string; href: string;
}> & }>) => {
Partial<PressableProps>) => {
const [moreOpened, setMoreOpened] = useState(false); const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episode-line"); const { css } = useYoshiki("episode-line");
const { t } = useTranslation(); const { t } = useTranslation();
@ -206,7 +238,7 @@ export const EpisodeLine = ({
}, },
}, },
}, },
props as any, props,
)} )}
> >
<P {...css({ width: rem(4), flexShrink: 0, m: ts(1), textAlign: "center" })}> <P {...css({ width: rem(4), flexShrink: 0, m: ts(1), textAlign: "center" })}>
@ -277,7 +309,7 @@ export const EpisodeLine = ({
setOpen={(v) => setMoreOpened(v)} setOpen={(v) => setMoreOpened(v)}
{...css([ {...css([
"more", "more",
Platform.OS === "web" && moreOpened && { display: "flex !important" as any }, Platform.OS === "web" && moreOpened && { display: important("flex") },
])} ])}
/> />
)} )}

View File

@ -57,6 +57,8 @@ export const NewsList = () => {
) : ( ) : (
<EpisodeBox <EpisodeBox
isLoading={x.isLoading as any} isLoading={x.isLoading as any}
slug={x.slug}
showSlug={x.showId}
name={ name={
x.kind === NewsKind.Episode x.kind === NewsKind.Episode
? `${x.show!.name} ${episodeDisplayNumber(x)}` ? `${x.show!.name} ${episodeDisplayNumber(x)}`

View File

@ -49,9 +49,13 @@ import { Layout, WithLoading } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite"; import { InfiniteFetch } from "../fetch-infinite";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg"; import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { ItemGrid, ItemWatchStatus } from "../browse/grid"; import { ItemGrid, ItemWatchStatus } from "../browse/grid";
import { useState } from "react";
import { ItemContext } from "../components/context-menus";
export const ItemDetails = ({ export const ItemDetails = ({
isLoading, isLoading,
slug,
type,
name, name,
tagline, tagline,
subtitle, subtitle,
@ -64,6 +68,8 @@ export const ItemDetails = ({
unseenEpisodesCount, unseenEpisodesCount,
...props ...props
}: WithLoading<{ }: WithLoading<{
slug: string;
type: "movie" | "show" | "collection";
name: string; name: string;
tagline: string | null; tagline: string | null;
subtitle: string; subtitle: string;
@ -75,8 +81,9 @@ export const ItemDetails = ({
watchStatus: WatchStatusV | null; watchStatus: WatchStatusV | null;
unseenEpisodesCount: number | null; unseenEpisodesCount: number | null;
}>) => { }>) => {
const { t } = useTranslation(); const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("recommanded-card"); const { css } = useYoshiki("recommanded-card");
const { t } = useTranslation();
return ( return (
<View <View
@ -88,7 +95,8 @@ export const ItemDetails = ({
)} )}
> >
<Link <Link
href={href} href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css({ {...css({
position: "absolute", position: "absolute",
top: 0, top: 0,
@ -149,11 +157,22 @@ export const ItemDetails = ({
<View <View
{...css({ flexShrink: 1, flexGrow: 1, justifyContent: "flex-end", marginBottom: px(50) })} {...css({ flexShrink: 1, flexGrow: 1, justifyContent: "flex-end", marginBottom: px(50) })}
> >
{(isLoading || tagline) && ( <View {...css({ flexDirection: "row-reverse", justifyContent: "space-between" })}>
<Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}> {slug && type && type !== "collection" && watchStatus !== undefined && (
{isLoading || <P {...css({ p: ts(1) })}>{tagline}</P>} <ItemContext
</Skeleton> type={type}
)} slug={slug}
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
/>
)}
{(isLoading || tagline) && (
<Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}>
{isLoading || <P {...css({ p: ts(1) })}>{tagline}</P>}
</Skeleton>
)}
</View>
<ScrollView {...css({ pX: ts(1) })}> <ScrollView {...css({ pX: ts(1) })}>
<Skeleton lines={5} {...css({ height: rem(0.8) })}> <Skeleton lines={5} {...css({ height: rem(0.8) })}>
{isLoading || ( {isLoading || (
@ -231,6 +250,8 @@ export const Recommanded = () => {
{(x) => ( {(x) => (
<ItemDetails <ItemDetails
isLoading={x.isLoading as any} isLoading={x.isLoading as any}
slug={x.slug}
type={x.kind?.toLowerCase() as any}
name={x.name} name={x.name}
tagline={"tagline" in x ? x.tagline : null} tagline={"tagline" in x ? x.tagline : null}
overview={x.overview} overview={x.overview}

View File

@ -61,11 +61,14 @@ export const WatchlistList = () => {
(x.isLoading && i % 2) ? ( (x.isLoading && i % 2) ? (
<EpisodeBox <EpisodeBox
isLoading={x.isLoading as any} isLoading={x.isLoading as any}
slug={episode?.slug}
showSlug={x.slug}
name={episode ? `${x.name} ${episodeDisplayNumber(episode)}` : undefined} name={episode ? `${x.name} ${episodeDisplayNumber(episode)}` : undefined}
overview={episode?.name} overview={episode?.name}
thumbnail={episode?.thumbnail ?? x.thumbnail} thumbnail={episode?.thumbnail ?? x.thumbnail}
href={episode?.href} href={episode?.href}
watchedPercent={x.watchStatus?.watchedPercent || null} watchedPercent={x.watchStatus?.watchedPercent || null}
watchedStatus={x.watchStatus?.status || null}
// TODO: Move this into the ItemList (using getItemSize) // TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property // @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })} {...css({ gridColumnEnd: "span 2" })}