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),
}),
/**
* The id of the show containing this episode
*/
showId: z.string(),
}),
"episodes",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,13 +28,17 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { watchListIcon } from "./watchlist-info";
export const EpisodesContext = ({
showSlug,
type = "episode",
slug,
showSlug,
status,
...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 { t } = useTranslation();
@ -42,10 +46,10 @@ export const EpisodesContext = ({
const mutation = useMutation({
mutationFn: (newStatus: WatchStatusV | null) =>
queryFn({
path: ["episode", slug, "watchStatus", newStatus && `?status=${newStatus}`],
path: [type, slug, "watchStatus", newStatus && `?status=${newStatus}`],
method: newStatus ? "POST" : "DELETE",
}),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["episode", slug] }),
onSettled: async () => await queryClient.invalidateQueries({ queryKey: [type, slug] }),
});
return (
@ -73,3 +77,16 @@ export const EpisodesContext = ({
</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,
ImageBackground,
ImageProps,
important,
Link,
P,
Skeleton,
@ -58,6 +59,8 @@ export const displayRuntime = (runtime: number) => {
};
export const EpisodeBox = ({
slug,
showSlug,
name,
overview,
thumbnail,
@ -68,6 +71,9 @@ export const EpisodeBox = ({
...props
}: Stylable &
WithLoading<{
slug: string;
// if show slug is null, disable "Go to show" in the context menu
showSlug: string | null;
name: string | null;
overview: string | null;
href: string;
@ -75,12 +81,14 @@ export const EpisodeBox = ({
watchedPercent: number | null;
watchedStatus: WatchStatusV | null;
}>) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episodebox");
const { t } = useTranslation();
return (
<Link
href={href}
href={moreOpened ? undefined : href}
onLongPress={() => setMoreOpened(true)}
{...css(
{
alignItems: "center",
@ -90,6 +98,9 @@ export const EpisodeBox = ({
borderWidth: ts(0.5),
borderStyle: "solid",
},
more: {
display: "none",
},
},
fover: {
self: focusReset,
@ -99,6 +110,9 @@ export const EpisodeBox = ({
title: {
textDecorationLine: "underline",
},
more: {
display: "flex",
},
},
},
props,
@ -117,6 +131,25 @@ export const EpisodeBox = ({
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
<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>
<Skeleton {...css({ width: percent(50) })}>
{isLoading || (
@ -177,8 +210,7 @@ export const EpisodeLine = ({
watchedPercent: number | null;
watchedStatus: WatchStatusV | null;
href: string;
}> &
Partial<PressableProps>) => {
}>) => {
const [moreOpened, setMoreOpened] = useState(false);
const { css } = useYoshiki("episode-line");
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" })}>
@ -277,7 +309,7 @@ export const EpisodeLine = ({
setOpen={(v) => setMoreOpened(v)}
{...css([
"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
isLoading={x.isLoading as any}
slug={x.slug}
showSlug={x.showId}
name={
x.kind === NewsKind.Episode
? `${x.show!.name} ${episodeDisplayNumber(x)}`

View File

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

View File

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