mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-06-23 15:30:34 -04:00
Split skeleton and normal state in all lists (#506)
This commit is contained in:
commit
a37ace7d46
@ -42,6 +42,7 @@ export const getDisplayDate = (data: Show | Movie) => {
|
|||||||
if (airDate) {
|
if (airDate) {
|
||||||
return airDate.getFullYear().toString();
|
return airDate.getFullYear().toString();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useLocalSetting = (setting: string, def: string) => {
|
export const useLocalSetting = (setting: string, def: string) => {
|
||||||
|
@ -23,6 +23,7 @@ import { type ComponentType, type RefAttributes, forwardRef } from "react";
|
|||||||
import { Image, type ImageProps, View, type ViewStyle } from "react-native";
|
import { Image, type ImageProps, View, type ViewStyle } from "react-native";
|
||||||
import { type Stylable, px, useYoshiki } from "yoshiki/native";
|
import { type Stylable, px, useYoshiki } from "yoshiki/native";
|
||||||
import { Icon } from "./icons";
|
import { Icon } from "./icons";
|
||||||
|
import { Skeleton } from "./skeleton";
|
||||||
import { P } from "./text";
|
import { P } from "./text";
|
||||||
|
|
||||||
const stringToColor = (string: string) => {
|
const stringToColor = (string: string) => {
|
||||||
@ -40,7 +41,7 @@ const stringToColor = (string: string) => {
|
|||||||
return color;
|
return color;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = forwardRef<
|
const AvatarC = forwardRef<
|
||||||
View,
|
View,
|
||||||
{
|
{
|
||||||
src?: string;
|
src?: string;
|
||||||
@ -48,12 +49,11 @@ export const Avatar = forwardRef<
|
|||||||
size?: number;
|
size?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
isLoading?: boolean;
|
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
|
as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
|
||||||
} & Stylable
|
} & Stylable
|
||||||
>(function Avatar(
|
>(function AvatarI(
|
||||||
{ src, alt, size = px(24), color, placeholder, isLoading = false, fill = false, as, ...props },
|
{ src, alt, size = px(24), color, placeholder, fill = false, as, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
@ -106,3 +106,22 @@ export const Avatar = forwardRef<
|
|||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AvatarLoader = ({ size = px(24), ...props }: { size?: number }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
variant="round"
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
height: size,
|
||||||
|
width: size,
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Avatar = Object.assign(AvatarC, { Loader: AvatarLoader });
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TextProps } from "react-native";
|
import { type TextProps, View } from "react-native";
|
||||||
import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
|
import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { Link } from "./links";
|
import { Link } from "./links";
|
||||||
import { Skeleton } from "./skeleton";
|
import { Skeleton } from "./skeleton";
|
||||||
@ -63,6 +63,7 @@ export const Chip = ({
|
|||||||
pX: ts(2.5 * sizeMult),
|
pX: ts(2.5 * sizeMult),
|
||||||
borderRadius: ts(3),
|
borderRadius: ts(3),
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
outline && {
|
outline && {
|
||||||
borderColor: color ?? ((theme: Theme) => theme.accent),
|
borderColor: color ?? ((theme: Theme) => theme.accent),
|
||||||
@ -102,3 +103,40 @@ export const Chip = ({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Chip.Loader = ({
|
||||||
|
color,
|
||||||
|
size = "medium",
|
||||||
|
outline = false,
|
||||||
|
...props
|
||||||
|
}: { color?: string; size?: "small" | "medium" | "large"; outline?: boolean }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pY: ts(1 * sizeMult),
|
||||||
|
pX: ts(2.5 * sizeMult),
|
||||||
|
borderRadius: ts(3),
|
||||||
|
overflow: "hidden",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
outline && {
|
||||||
|
borderColor: color ?? ((theme: Theme) => theme.accent),
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderWidth: px(1),
|
||||||
|
},
|
||||||
|
!outline && {
|
||||||
|
bg: color ?? ((theme: Theme) => theme.accent),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Skeleton {...css({ width: rem(3) })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
@ -45,7 +45,7 @@ type IconProps = {
|
|||||||
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
|
||||||
const { css, theme } = useYoshiki();
|
const { css, theme } = useYoshiki();
|
||||||
const computed = css(
|
const computed = css(
|
||||||
{ width: size, height: size, fill: color ?? theme.contrast } as any,
|
{ width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any,
|
||||||
props,
|
props,
|
||||||
) as any;
|
) as any;
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCurrentToken } from "@kyoo/models";
|
import { getCurrentToken } from "@kyoo/models";
|
||||||
import { useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
|
import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
|
||||||
import { Blurhash } from "react-native-blurhash";
|
import { Blurhash } from "react-native-blurhash";
|
||||||
import FastImage from "react-native-fast-image";
|
import FastImage from "react-native-fast-image";
|
||||||
@ -93,3 +93,10 @@ export const Image = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
|
||||||
|
|
||||||
|
return <Skeleton variant="custom" show {...css([layout, border], props)} />;
|
||||||
|
};
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import NextImage from "next/image";
|
import NextImage from "next/image";
|
||||||
import { useState } from "react";
|
import { type ReactElement, useState } from "react";
|
||||||
import { type ImageStyle, View, type ViewStyle } from "react-native";
|
import { type ImageStyle, View, type ViewStyle } from "react-native";
|
||||||
import { useYoshiki } from "yoshiki/native";
|
import { useYoshiki } from "yoshiki/native";
|
||||||
import { imageBorderRadius } from "../constants";
|
import { imageBorderRadius } from "../constants";
|
||||||
@ -73,3 +73,10 @@ export const Image = ({
|
|||||||
</BlurhashContainer>
|
</BlurhashContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Image.Loader = ({ layout, ...props }: { layout: ImageLayout; children?: ReactElement }) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
|
||||||
|
|
||||||
|
return <Skeleton variant="custom" show {...css([layout, border], props)} />;
|
||||||
|
};
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
|
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
|
||||||
import type { ComponentProps, ComponentType, ReactNode } from "react";
|
import type { ComponentProps, ComponentType, ReactElement, ReactNode } from "react";
|
||||||
import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
|
import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
|
||||||
import { percent } from "yoshiki/native";
|
import { percent } from "yoshiki/native";
|
||||||
import { imageBorderRadius } from "../constants";
|
import { imageBorderRadius } from "../constants";
|
||||||
@ -39,6 +39,14 @@ export const Poster = ({
|
|||||||
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||||
}) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
}) => <Image alt={alt!} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||||
|
|
||||||
|
Poster.Loader = ({
|
||||||
|
layout,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children?: ReactElement;
|
||||||
|
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
|
||||||
|
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
|
||||||
|
|
||||||
export const PosterBackground = ({
|
export const PosterBackground = ({
|
||||||
alt,
|
alt,
|
||||||
layout,
|
layout,
|
||||||
@ -86,7 +94,7 @@ export const ImageBackground = <AsProps = ViewProps>({
|
|||||||
{({ css, theme }) => (
|
{({ css, theme }) => (
|
||||||
<Container
|
<Container
|
||||||
{...(css(
|
{...(css(
|
||||||
[layout, !hideLoad && { borderRadius: imageBorderRadius, overflow: "hidden" }],
|
[layout, { borderRadius: imageBorderRadius, overflow: "hidden" }],
|
||||||
asProps,
|
asProps,
|
||||||
) as AsProps)}
|
) as AsProps)}
|
||||||
>
|
>
|
||||||
|
@ -19,11 +19,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { LinearGradient as LG } from "expo-linear-gradient";
|
import { LinearGradient as LG } from "expo-linear-gradient";
|
||||||
import { AnimatePresence, MotiView, motify } from "moti";
|
import { MotiView, motify } from "moti";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { hiddenIfNoJs } from "./utils/nojs";
|
|
||||||
|
|
||||||
const LinearGradient = motify(LG)();
|
const LinearGradient = motify(LG)();
|
||||||
|
|
||||||
@ -99,71 +98,59 @@ export const Skeleton = ({
|
|||||||
props,
|
props,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
{(forcedShow || !children || children === true) &&
|
||||||
{children}
|
[...Array(lines)].map((_, i) => (
|
||||||
{(forcedShow || !children || children === true) &&
|
<MotiView
|
||||||
[...Array(lines)].map((_, i) => (
|
key={`skeleton_${i}`}
|
||||||
<MotiView
|
// No clue why it is a number on mobile and a string on web but /shrug
|
||||||
key={`skeleton_${i}`}
|
animate={{ opacity: Platform.OS === "web" ? ("1" as any) : 1 }}
|
||||||
// No clue why it is a number on mobile and a string on web but /shrug
|
exit={{ opacity: 0 }}
|
||||||
animate={{ opacity: Platform.OS === "web" ? ("1" as any) : 1 }}
|
transition={{ type: "timing" }}
|
||||||
exit={{ opacity: 0 }}
|
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
||||||
transition={{ type: "timing" }}
|
{...css([
|
||||||
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
{
|
||||||
{...css(
|
bg: (theme) => theme.overlay0,
|
||||||
[
|
},
|
||||||
{
|
lines === 1 && {
|
||||||
bg: (theme) => theme.overlay0,
|
position: "absolute",
|
||||||
},
|
top: 0,
|
||||||
lines === 1 && {
|
bottom: 0,
|
||||||
position: "absolute",
|
left: 0,
|
||||||
top: 0,
|
right: 0,
|
||||||
bottom: 0,
|
},
|
||||||
left: 0,
|
lines !== 1 && {
|
||||||
right: 0,
|
width: i === lines - 1 ? percent(40) : percent(100),
|
||||||
},
|
height: rem(1.2),
|
||||||
lines !== 1 && {
|
marginBottom: rem(0.5),
|
||||||
width: i === lines - 1 ? percent(40) : percent(100),
|
overflow: "hidden",
|
||||||
height: rem(1.2),
|
borderRadius: px(6),
|
||||||
marginBottom: rem(0.5),
|
},
|
||||||
overflow: "hidden",
|
])}
|
||||||
borderRadius: px(6),
|
>
|
||||||
},
|
<LinearGradient
|
||||||
],
|
start={{ x: 0, y: 0.5 }}
|
||||||
hiddenIfNoJs,
|
end={{ x: 1, y: 0.5 }}
|
||||||
)}
|
colors={["transparent", theme.overlay1, "transparent"]}
|
||||||
>
|
transition={{
|
||||||
<LinearGradient
|
loop: true,
|
||||||
start={{ x: 0, y: 0.5 }}
|
repeatReverse: false,
|
||||||
end={{ x: 1, y: 0.5 }}
|
}}
|
||||||
colors={["transparent", theme.overlay1, "transparent"]}
|
animate={{
|
||||||
transition={{
|
translateX: width
|
||||||
loop: true,
|
? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
|
||||||
repeatReverse: false,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
animate={{
|
{...css({
|
||||||
translateX: width
|
position: "absolute",
|
||||||
? [perc(-100), { value: perc(100), type: "timing", duration: 800, delay: 800 }]
|
top: 0,
|
||||||
: undefined,
|
bottom: 0,
|
||||||
}}
|
left: 0,
|
||||||
{...css([
|
right: 0,
|
||||||
{
|
})}
|
||||||
position: "absolute",
|
/>
|
||||||
top: 0,
|
</MotiView>
|
||||||
bottom: 0,
|
))}
|
||||||
left: 0,
|
{children}
|
||||||
right: 0,
|
|
||||||
},
|
|
||||||
Platform.OS === "web" && {
|
|
||||||
// @ts-ignore Web only properties
|
|
||||||
animation: "skeleton 1.6s linear 0.5s infinite",
|
|
||||||
transform: "translateX(-100%)",
|
|
||||||
},
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
</MotiView>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -21,7 +21,6 @@
|
|||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { View, type ViewProps } from "react-native";
|
import { View, type ViewProps } from "react-native";
|
||||||
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { hiddenIfNoJs } from "./utils/nojs";
|
|
||||||
|
|
||||||
export const SkeletonCss = () => (
|
export const SkeletonCss = () => (
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
@ -90,33 +89,29 @@ export const Skeleton = ({
|
|||||||
props,
|
props,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
|
||||||
{(forcedShow || !children || children === true) &&
|
{(forcedShow || !children || children === true) &&
|
||||||
[...Array(lines)].map((_, i) => (
|
[...Array(lines)].map((_, i) => (
|
||||||
<View
|
<View
|
||||||
key={`skeleton_${i}`}
|
key={`skeleton_${i}`}
|
||||||
{...css(
|
{...css([
|
||||||
[
|
{
|
||||||
{
|
bg: (theme) => theme.overlay0,
|
||||||
bg: (theme) => theme.overlay0,
|
},
|
||||||
},
|
lines === 1 && {
|
||||||
lines === 1 && {
|
position: "absolute",
|
||||||
position: "absolute",
|
top: 0,
|
||||||
top: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
},
|
||||||
},
|
lines !== 1 && {
|
||||||
lines !== 1 && {
|
width: i === lines - 1 ? percent(40) : percent(100),
|
||||||
width: i === lines - 1 ? percent(40) : percent(100),
|
height: rem(1.2),
|
||||||
height: rem(1.2),
|
marginBottom: rem(0.5),
|
||||||
marginBottom: rem(0.5),
|
overflow: "hidden",
|
||||||
overflow: "hidden",
|
borderRadius: px(6),
|
||||||
borderRadius: px(6),
|
},
|
||||||
},
|
])}
|
||||||
],
|
|
||||||
hiddenIfNoJs,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
start={{ x: 0, y: 0.5 }}
|
start={{ x: 0, y: 0.5 }}
|
||||||
@ -137,6 +132,7 @@ export const Skeleton = ({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
{children}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -23,7 +23,7 @@ import { Alert, Avatar, Icon, IconButton, Menu, P, Skeleton, tooltip, ts } from
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { px, useYoshiki } from "yoshiki/native";
|
import { px, useYoshiki } from "yoshiki/native";
|
||||||
import type { Layout, WithLoading } from "../fetch";
|
import type { Layout } from "../fetch";
|
||||||
import { InfiniteFetch } from "../fetch-infinite";
|
import { InfiniteFetch } from "../fetch-infinite";
|
||||||
import { SettingsContainer } from "../settings/base";
|
import { SettingsContainer } from "../settings/base";
|
||||||
|
|
||||||
@ -36,20 +36,19 @@ import Verifed from "@material-symbols/svg-400/rounded/verified_user.svg";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const UserGrid = ({
|
export const UserGrid = ({
|
||||||
isLoading,
|
|
||||||
id,
|
id,
|
||||||
username,
|
username,
|
||||||
avatar,
|
avatar,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isVerified,
|
isVerified,
|
||||||
...props
|
...props
|
||||||
}: WithLoading<{
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
}>) => {
|
}) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -66,11 +65,10 @@ export const UserGrid = ({
|
|||||||
return (
|
return (
|
||||||
<View {...css({ alignItems: "center" }, props)}>
|
<View {...css({ alignItems: "center" }, props)}>
|
||||||
<Avatar src={avatar} alt={username} placeholder={username} size={UserGrid.layout.size} fill />
|
<Avatar src={avatar} alt={username} placeholder={username} size={UserGrid.layout.size} fill />
|
||||||
<View {...css({ flexDirection: "row" })}>
|
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||||
<Icon
|
<Icon
|
||||||
icon={!isVerified ? Unverifed : isAdmin ? Admin : UserI}
|
icon={!isVerified ? Unverifed : isAdmin ? Admin : UserI}
|
||||||
{...css({
|
{...css({
|
||||||
alignSelf: "center",
|
|
||||||
m: ts(1),
|
m: ts(1),
|
||||||
})}
|
})}
|
||||||
{...tooltip(
|
{...tooltip(
|
||||||
@ -83,9 +81,7 @@ export const UserGrid = ({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Skeleton>
|
<P>{username}</P>
|
||||||
<P>{username}</P>
|
|
||||||
</Skeleton>
|
|
||||||
<Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))}>
|
<Menu Trigger={IconButton} icon={MoreVert} {...tooltip(t("misc.more"))}>
|
||||||
{!isVerified && (
|
{!isVerified && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@ -159,6 +155,21 @@ export const UserGrid = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
UserGrid.Loader = (props: object) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...css({ alignItems: "center" }, props)}>
|
||||||
|
<Avatar.Loader size={UserGrid.layout.size} />
|
||||||
|
<View {...css({ flexDirection: "row", alignItems: "center", flexShrink: 1, flexGrow: 1 })}>
|
||||||
|
<Icon icon={UserI} {...css({ m: ts(1) })} />
|
||||||
|
<Skeleton {...css({ flexGrow: 1, width: ts(8) })} />
|
||||||
|
<IconButton icon={MoreVert} disabled />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
UserGrid.layout = {
|
UserGrid.layout = {
|
||||||
size: px(150),
|
size: px(150),
|
||||||
numColumns: { xs: 2, sm: 3, md: 5, lg: 6, xl: 7 },
|
numColumns: { xs: 2, sm: 3, md: 5, lg: 6, xl: 7 },
|
||||||
@ -171,18 +182,20 @@ export const UserList = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer title={t("admin.users.label")}>
|
<SettingsContainer title={t("admin.users.label")}>
|
||||||
<InfiniteFetch query={UserList.query()} layout={UserGrid.layout}>
|
<InfiniteFetch
|
||||||
{(user) => (
|
query={UserList.query()}
|
||||||
|
layout={UserGrid.layout}
|
||||||
|
Render={({ item }) => (
|
||||||
<UserGrid
|
<UserGrid
|
||||||
isLoading={user.isLoading as any}
|
id={item.id}
|
||||||
id={user.id}
|
username={item.username}
|
||||||
username={user.username}
|
avatar={item.logo}
|
||||||
avatar={user.logo}
|
isAdmin={item.isAdmin}
|
||||||
isAdmin={user.isAdmin}
|
isVerified={item.isVerified}
|
||||||
isVerified={user.isVerified}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InfiniteFetch>
|
Loader={UserGrid.Loader}
|
||||||
|
/>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -23,6 +23,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
Link,
|
Link,
|
||||||
P,
|
P,
|
||||||
|
Poster,
|
||||||
PosterBackground,
|
PosterBackground,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SubP,
|
SubP,
|
||||||
@ -35,7 +36,7 @@ import { useState } from "react";
|
|||||||
import { type ImageStyle, Platform, View } from "react-native";
|
import { type ImageStyle, Platform, View } from "react-native";
|
||||||
import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
|
import { type Stylable, type Theme, max, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { ItemContext } from "../components/context-menus";
|
import { ItemContext } from "../components/context-menus";
|
||||||
import type { Layout, WithLoading } from "../fetch";
|
import type { Layout } from "../fetch";
|
||||||
|
|
||||||
export const ItemWatchStatus = ({
|
export const ItemWatchStatus = ({
|
||||||
watchStatus,
|
watchStatus,
|
||||||
@ -113,23 +114,21 @@ export const ItemGrid = ({
|
|||||||
type,
|
type,
|
||||||
subtitle,
|
subtitle,
|
||||||
poster,
|
poster,
|
||||||
isLoading,
|
|
||||||
watchStatus,
|
watchStatus,
|
||||||
watchPercent,
|
watchPercent,
|
||||||
unseenEpisodesCount,
|
unseenEpisodesCount,
|
||||||
...props
|
...props
|
||||||
}: WithLoading<{
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle: string | null;
|
||||||
poster?: KyooImage | null;
|
poster: KyooImage | null;
|
||||||
watchStatus: WatchStatusV | null;
|
watchStatus: WatchStatusV | null;
|
||||||
watchPercent: number | null;
|
watchPercent: number | null;
|
||||||
type: "movie" | "show" | "collection";
|
type: "movie" | "show" | "collection";
|
||||||
unseenEpisodesCount: number | null;
|
unseenEpisodesCount: number | null;
|
||||||
}> &
|
} & Stylable<"text">) => {
|
||||||
Stylable<"text">) => {
|
|
||||||
const [moreOpened, setMoreOpened] = useState(false);
|
const [moreOpened, setMoreOpened] = useState(false);
|
||||||
const { css } = useYoshiki("grid");
|
const { css } = useYoshiki("grid");
|
||||||
|
|
||||||
@ -172,13 +171,12 @@ export const ItemGrid = ({
|
|||||||
src={poster}
|
src={poster}
|
||||||
alt={name}
|
alt={name}
|
||||||
quality="low"
|
quality="low"
|
||||||
forcedLoading={isLoading}
|
|
||||||
layout={{ width: percent(100) }}
|
layout={{ width: percent(100) }}
|
||||||
{...(css("poster") as { style: ImageStyle })}
|
{...(css("poster") as { style: ImageStyle })}
|
||||||
>
|
>
|
||||||
<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" && (
|
{type !== "collection" && (
|
||||||
<ItemContext
|
<ItemContext
|
||||||
type={type}
|
type={type}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
@ -198,34 +196,44 @@ export const ItemGrid = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</PosterBackground>
|
</PosterBackground>
|
||||||
<Skeleton>
|
<P numberOfLines={subtitle ? 1 : 2} {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
|
||||||
{isLoading || (
|
{name}
|
||||||
<P
|
</P>
|
||||||
numberOfLines={subtitle ? 1 : 2}
|
{subtitle && (
|
||||||
{...css([{ marginY: 0, textAlign: "center" }, "title"])}
|
<SubP
|
||||||
>
|
{...css({
|
||||||
{name}
|
marginTop: 0,
|
||||||
</P>
|
textAlign: "center",
|
||||||
)}
|
})}
|
||||||
</Skeleton>
|
>
|
||||||
{(isLoading || subtitle) && (
|
{subtitle}
|
||||||
<Skeleton {...css({ width: percent(50) })}>
|
</SubP>
|
||||||
{isLoading || (
|
|
||||||
<SubP
|
|
||||||
{...css({
|
|
||||||
marginTop: 0,
|
|
||||||
textAlign: "center",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</SubP>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ItemGrid.Loader = (props: object) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
width: percent(100),
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Poster.Loader layout={{ width: percent(100) }} />
|
||||||
|
<Skeleton />
|
||||||
|
<Skeleton {...css({ width: percent(50) })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
ItemGrid.layout = {
|
ItemGrid.layout = {
|
||||||
size: px(150),
|
size: px(150),
|
||||||
numColumns: { xs: 3, sm: 4, md: 5, lg: 6, xl: 8 },
|
numColumns: { xs: 3, sm: 4, md: 5, lg: 6, xl: 8 },
|
||||||
|
@ -27,7 +27,6 @@ import {
|
|||||||
} from "@kyoo/models";
|
} from "@kyoo/models";
|
||||||
import { type ComponentProps, useState } from "react";
|
import { type ComponentProps, useState } from "react";
|
||||||
import { createParam } from "solito";
|
import { createParam } from "solito";
|
||||||
import type { WithLoading } from "../fetch";
|
|
||||||
import { InfiniteFetch } from "../fetch-infinite";
|
import { InfiniteFetch } from "../fetch-infinite";
|
||||||
import { DefaultLayout } from "../layout";
|
import { DefaultLayout } from "../layout";
|
||||||
import { ItemGrid } from "./grid";
|
import { ItemGrid } from "./grid";
|
||||||
@ -38,25 +37,20 @@ import { Layout, SortBy, SortOrd } from "./types";
|
|||||||
const { useParam } = createParam<{ sortBy?: string }>();
|
const { useParam } = createParam<{ sortBy?: string }>();
|
||||||
|
|
||||||
export const itemMap = (
|
export const itemMap = (
|
||||||
item: WithLoading<LibraryItem>,
|
item: LibraryItem,
|
||||||
): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => {
|
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
|
||||||
if (item.isLoading) return item as any;
|
slug: item.slug,
|
||||||
|
name: item.name,
|
||||||
return {
|
subtitle: item.kind !== "collection" ? getDisplayDate(item) : null,
|
||||||
isLoading: item.isLoading,
|
href: item.href,
|
||||||
slug: item.slug,
|
poster: item.poster,
|
||||||
name: item.name,
|
thumbnail: item.thumbnail,
|
||||||
subtitle: item.kind !== "collection" ? getDisplayDate(item) : undefined,
|
watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
|
||||||
href: item.href,
|
type: item.kind,
|
||||||
poster: item.poster,
|
watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
|
||||||
thumbnail: item.thumbnail,
|
unseenEpisodesCount:
|
||||||
watchStatus: item.kind !== "collection" ? item.watchStatus?.status ?? null : null,
|
item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
|
||||||
type: item.kind,
|
});
|
||||||
watchPercent: item.kind !== "collection" ? item.watchStatus?.watchedPercent ?? null : null,
|
|
||||||
unseenEpisodesCount:
|
|
||||||
item.kind === "show" ? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount! : null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier<LibraryItem> => ({
|
const query = (sortKey?: SortBy, sortOrd?: SortOrd): QueryIdentifier<LibraryItem> => ({
|
||||||
parser: LibraryItemP,
|
parser: LibraryItemP,
|
||||||
@ -92,9 +86,9 @@ export const BrowsePage: QueryPage = () => {
|
|||||||
setLayout={setLayout}
|
setLayout={setLayout}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
Render={({ item }) => <LayoutComponent {...itemMap(item)} />}
|
||||||
{(item) => <LayoutComponent {...itemMap(item)} />}
|
Loader={LayoutComponent.Loader}
|
||||||
</InfiniteFetch>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -24,6 +24,7 @@ import {
|
|||||||
ImageBackground,
|
ImageBackground,
|
||||||
Link,
|
Link,
|
||||||
P,
|
P,
|
||||||
|
Poster,
|
||||||
PosterBackground,
|
PosterBackground,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
imageBorderRadius,
|
imageBorderRadius,
|
||||||
@ -32,9 +33,10 @@ import {
|
|||||||
} from "@kyoo/primitives";
|
} from "@kyoo/primitives";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Platform, View } from "react-native";
|
import { Platform, View } from "react-native";
|
||||||
|
import type { Stylable } from "yoshiki";
|
||||||
import { percent, px, rem, useYoshiki } from "yoshiki/native";
|
import { percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { ItemContext } from "../components/context-menus";
|
import { ItemContext } from "../components/context-menus";
|
||||||
import type { Layout, WithLoading } from "../fetch";
|
import type { Layout } from "../fetch";
|
||||||
import { ItemWatchStatus } from "./grid";
|
import { ItemWatchStatus } from "./grid";
|
||||||
|
|
||||||
export const ItemList = ({
|
export const ItemList = ({
|
||||||
@ -45,22 +47,21 @@ export const ItemList = ({
|
|||||||
subtitle,
|
subtitle,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
poster,
|
poster,
|
||||||
isLoading,
|
|
||||||
watchStatus,
|
watchStatus,
|
||||||
unseenEpisodesCount,
|
unseenEpisodesCount,
|
||||||
...props
|
...props
|
||||||
}: WithLoading<{
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
type: "movie" | "show" | "collection";
|
type: "movie" | "show" | "collection";
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle: string | null;
|
||||||
poster?: KyooImage | null;
|
poster: KyooImage | null;
|
||||||
thumbnail?: KyooImage | null;
|
thumbnail: KyooImage | null;
|
||||||
watchStatus: WatchStatusV | null;
|
watchStatus: WatchStatusV | null;
|
||||||
unseenEpisodesCount: number | null;
|
unseenEpisodesCount: number | null;
|
||||||
}>) => {
|
}) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki("line");
|
||||||
const [moreOpened, setMoreOpened] = useState(false);
|
const [moreOpened, setMoreOpened] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -114,25 +115,21 @@ export const ItemList = ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Skeleton {...css({ height: rem(2), alignSelf: "center" })}>
|
<Heading
|
||||||
{isLoading || (
|
{...css([
|
||||||
<Heading
|
"title",
|
||||||
{...css([
|
{
|
||||||
"title",
|
textAlign: "center",
|
||||||
{
|
fontSize: rem(2),
|
||||||
textAlign: "center",
|
letterSpacing: rem(0.002),
|
||||||
fontSize: rem(2),
|
fontWeight: "900",
|
||||||
letterSpacing: rem(0.002),
|
textTransform: "uppercase",
|
||||||
fontWeight: "900",
|
},
|
||||||
textTransform: "uppercase",
|
])}
|
||||||
},
|
>
|
||||||
])}
|
{name}
|
||||||
>
|
</Heading>
|
||||||
{name}
|
{type !== "collection" && (
|
||||||
</Heading>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
{slug && watchStatus !== undefined && type && type !== "collection" && (
|
|
||||||
<ItemContext
|
<ItemContext
|
||||||
type={type}
|
type={type}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
@ -151,32 +148,56 @@ export const ItemList = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{(isLoading || subtitle) && (
|
{subtitle && (
|
||||||
<Skeleton {...css({ width: rem(5), alignSelf: "center" })}>
|
<P
|
||||||
{isLoading || (
|
{...css({
|
||||||
<P
|
textAlign: "center",
|
||||||
{...css({
|
marginRight: ts(4),
|
||||||
textAlign: "center",
|
})}
|
||||||
marginRight: ts(4),
|
>
|
||||||
})}
|
{subtitle}
|
||||||
>
|
</P>
|
||||||
{subtitle}
|
|
||||||
</P>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<PosterBackground
|
<PosterBackground src={poster} alt="" quality="low" layout={{ height: percent(80) }}>
|
||||||
src={poster}
|
|
||||||
alt=""
|
|
||||||
quality="low"
|
|
||||||
forcedLoading={isLoading}
|
|
||||||
layout={{ height: percent(80) }}
|
|
||||||
>
|
|
||||||
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
|
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
|
||||||
</PosterBackground>
|
</PosterBackground>
|
||||||
</ImageBackground>
|
</ImageBackground>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ItemList.Loader = (props: object) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-evenly",
|
||||||
|
flexDirection: "row",
|
||||||
|
height: ItemList.layout.size,
|
||||||
|
borderRadius: px(imageBorderRadius),
|
||||||
|
overflow: "hidden",
|
||||||
|
bg: (theme) => theme.dark.background,
|
||||||
|
marginX: ItemList.layout.gap,
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
width: { xs: "50%", lg: "30%" },
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Skeleton {...css({ height: rem(2), alignSelf: "center" })} />
|
||||||
|
<Skeleton {...css({ width: rem(5), alignSelf: "center" })} />
|
||||||
|
</View>
|
||||||
|
<Poster.Loader layout={{ height: percent(80) }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout;
|
ItemList.layout = { numColumns: 1, size: 300, layout: "vertical", gap: ts(2) } satisfies Layout;
|
||||||
|
@ -155,30 +155,29 @@ export const CollectionPage: QueryPage<{ slug: string }> = ({ slug }) => {
|
|||||||
Header={CollectionHeader}
|
Header={CollectionHeader}
|
||||||
headerProps={{ slug }}
|
headerProps={{ slug }}
|
||||||
contentContainerStyle={{ padding: 0, paddingHorizontal: 0, ...pageStyle }}
|
contentContainerStyle={{ padding: 0, paddingHorizontal: 0, ...pageStyle }}
|
||||||
>
|
Render={({ item }) => (
|
||||||
{(x) => (
|
|
||||||
<ItemDetails
|
<ItemDetails
|
||||||
isLoading={x.isLoading as any}
|
slug={item.slug}
|
||||||
slug={x.slug}
|
type={item.kind}
|
||||||
type={x.kind}
|
name={item.name}
|
||||||
name={x.name}
|
tagline={"tagline" in item ? item.tagline : null}
|
||||||
tagline={"tagline" in x ? x.tagline : null}
|
overview={item.overview}
|
||||||
overview={x.overview}
|
poster={item.poster}
|
||||||
poster={x.poster}
|
subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
|
||||||
subtitle={x.kind !== "collection" && !x.isLoading ? getDisplayDate(x) : undefined}
|
genres={"genres" in item ? item.genres : null}
|
||||||
genres={"genres" in x ? x.genres : null}
|
href={item.href}
|
||||||
href={x.href}
|
playHref={item.kind !== "collection" ? item.playHref : null}
|
||||||
playHref={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined}
|
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
|
||||||
watchStatus={
|
|
||||||
!x.isLoading && x.kind !== "collection" ? x.watchStatus?.status ?? null : null
|
|
||||||
}
|
|
||||||
unseenEpisodesCount={
|
unseenEpisodesCount={
|
||||||
x.kind === "show" ? x.watchStatus?.unseenEpisodesCount ?? x.episodesCount! : null
|
item.kind === "show"
|
||||||
|
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
{...css({ marginX: ItemGrid.layout.gap })}
|
{...css({ marginX: ItemGrid.layout.gap })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InfiniteFetch>
|
Loader={ItemDetails.Loader}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ import { type KyooImage, WatchStatusV } from "@kyoo/models";
|
|||||||
import {
|
import {
|
||||||
H6,
|
H6,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Image,
|
||||||
ImageBackground,
|
ImageBackground,
|
||||||
type ImageProps,
|
type ImageProps,
|
||||||
Link,
|
Link,
|
||||||
@ -41,20 +42,17 @@ import { type ImageStyle, Platform, type PressableProps, View } from "react-nati
|
|||||||
import { type Stylable, type Theme, percent, rem, useYoshiki } from "yoshiki/native";
|
import { type Stylable, type Theme, percent, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { ItemProgress } from "../browse/grid";
|
import { ItemProgress } from "../browse/grid";
|
||||||
import { EpisodesContext } from "../components/context-menus";
|
import { EpisodesContext } from "../components/context-menus";
|
||||||
import type { Layout, WithLoading } from "../fetch";
|
import type { Layout } from "../fetch";
|
||||||
|
|
||||||
export const episodeDisplayNumber = (
|
export const episodeDisplayNumber = (episode: {
|
||||||
episode: {
|
seasonNumber?: number | null;
|
||||||
seasonNumber?: number | null;
|
episodeNumber?: number | null;
|
||||||
episodeNumber?: number | null;
|
absoluteNumber?: number | null;
|
||||||
absoluteNumber?: number | null;
|
}) => {
|
||||||
},
|
|
||||||
def?: string,
|
|
||||||
) => {
|
|
||||||
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
|
if (typeof episode.seasonNumber === "number" && typeof episode.episodeNumber === "number")
|
||||||
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
|
return `S${episode.seasonNumber}:E${episode.episodeNumber}`;
|
||||||
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
|
if (episode.absoluteNumber) return episode.absoluteNumber.toString();
|
||||||
return def;
|
return "??";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const displayRuntime = (runtime: number | null) => {
|
export const displayRuntime = (runtime: number | null) => {
|
||||||
@ -69,23 +67,21 @@ export const EpisodeBox = ({
|
|||||||
name,
|
name,
|
||||||
overview,
|
overview,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
isLoading,
|
|
||||||
href,
|
href,
|
||||||
watchedPercent,
|
watchedPercent,
|
||||||
watchedStatus,
|
watchedStatus,
|
||||||
...props
|
...props
|
||||||
}: Stylable &
|
}: Stylable & {
|
||||||
WithLoading<{
|
slug: string;
|
||||||
slug: string;
|
// if show slug is null, disable "Go to show" in the context menu
|
||||||
// if show slug is null, disable "Go to show" in the context menu
|
showSlug: string | null;
|
||||||
showSlug: string | null;
|
name: string | null;
|
||||||
name: string | null;
|
overview: string | null;
|
||||||
overview: string | null;
|
href: string;
|
||||||
href: string;
|
thumbnail?: ImageProps["src"] | null;
|
||||||
thumbnail?: ImageProps["src"] | null;
|
watchedPercent: number | null;
|
||||||
watchedPercent: number | null;
|
watchedStatus: WatchStatusV | null;
|
||||||
watchedStatus: WatchStatusV | null;
|
}) => {
|
||||||
}>) => {
|
|
||||||
const [moreOpened, setMoreOpened] = useState(false);
|
const [moreOpened, setMoreOpened] = useState(false);
|
||||||
const { css } = useYoshiki("episodebox");
|
const { css } = useYoshiki("episodebox");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -128,58 +124,65 @@ export const EpisodeBox = ({
|
|||||||
quality="low"
|
quality="low"
|
||||||
alt=""
|
alt=""
|
||||||
gradient={false}
|
gradient={false}
|
||||||
hideLoad={false}
|
|
||||||
forcedLoading={isLoading}
|
|
||||||
layout={{ width: percent(100), aspectRatio: 16 / 9 }}
|
layout={{ width: percent(100), aspectRatio: 16 / 9 }}
|
||||||
{...(css("poster") as any)}
|
{...(css("poster") as any)}
|
||||||
>
|
>
|
||||||
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
|
{(watchedPercent || watchedStatus === WatchStatusV.Completed) && (
|
||||||
<ItemProgress watchPercent={watchedPercent ?? 100} />
|
<ItemProgress watchPercent={watchedPercent ?? 100} />
|
||||||
)}
|
)}
|
||||||
{slug && watchedStatus !== undefined && (
|
<EpisodesContext
|
||||||
<EpisodesContext
|
slug={slug}
|
||||||
slug={slug}
|
showSlug={showSlug}
|
||||||
showSlug={showSlug}
|
status={watchedStatus}
|
||||||
status={watchedStatus}
|
isOpen={moreOpened}
|
||||||
isOpen={moreOpened}
|
setOpen={(v) => setMoreOpened(v)}
|
||||||
setOpen={(v) => setMoreOpened(v)}
|
{...css([
|
||||||
{...css([
|
{
|
||||||
{
|
position: "absolute",
|
||||||
position: "absolute",
|
top: 0,
|
||||||
top: 0,
|
right: 0,
|
||||||
right: 0,
|
bg: (theme) => theme.darkOverlay,
|
||||||
bg: (theme) => theme.darkOverlay,
|
},
|
||||||
},
|
"more",
|
||||||
"more",
|
Platform.OS === "web" && moreOpened && { display: important("flex") },
|
||||||
Platform.OS === "web" && moreOpened && { display: important("flex") },
|
])}
|
||||||
])}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ImageBackground>
|
</ImageBackground>
|
||||||
<Skeleton {...css({ width: percent(50) })}>
|
<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
|
||||||
{isLoading || (
|
{name ?? t("show.episodeNoMetadata")}
|
||||||
<P {...css([{ marginY: 0, textAlign: "center" }, "title"])}>
|
</P>
|
||||||
{name ?? t("show.episodeNoMetadata")}
|
<SubP
|
||||||
</P>
|
numberOfLines={3}
|
||||||
)}
|
{...css({
|
||||||
</Skeleton>
|
marginTop: 0,
|
||||||
<Skeleton {...css({ width: percent(75), height: rem(0.8) })}>
|
textAlign: "center",
|
||||||
{isLoading || (
|
})}
|
||||||
<SubP
|
>
|
||||||
numberOfLines={3}
|
{overview}
|
||||||
{...css({
|
</SubP>
|
||||||
marginTop: 0,
|
|
||||||
textAlign: "center",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{overview}
|
|
||||||
</SubP>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
EpisodeBox.Loader = (props: Stylable) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image.Loader layout={{ width: percent(100), aspectRatio: 16 / 9 }} />
|
||||||
|
<Skeleton {...css({ width: percent(50) })} />
|
||||||
|
<Skeleton {...css({ width: percent(75), height: rem(0.8) })} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const EpisodeLine = ({
|
export const EpisodeLine = ({
|
||||||
slug,
|
slug,
|
||||||
showSlug,
|
showSlug,
|
||||||
@ -187,7 +190,6 @@ export const EpisodeLine = ({
|
|||||||
name,
|
name,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
overview,
|
overview,
|
||||||
isLoading,
|
|
||||||
id,
|
id,
|
||||||
absoluteNumber,
|
absoluteNumber,
|
||||||
episodeNumber,
|
episodeNumber,
|
||||||
@ -198,7 +200,7 @@ export const EpisodeLine = ({
|
|||||||
watchedStatus,
|
watchedStatus,
|
||||||
href,
|
href,
|
||||||
...props
|
...props
|
||||||
}: WithLoading<{
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
// if show slug is null, disable "Go to show" in the context menu
|
// if show slug is null, disable "Go to show" in the context menu
|
||||||
@ -215,8 +217,7 @@ export const EpisodeLine = ({
|
|||||||
watchedPercent: number | null;
|
watchedPercent: number | null;
|
||||||
watchedStatus: WatchStatusV | null;
|
watchedStatus: WatchStatusV | null;
|
||||||
href: string;
|
href: string;
|
||||||
}> &
|
} & PressableProps &
|
||||||
PressableProps &
|
|
||||||
Stylable) => {
|
Stylable) => {
|
||||||
const [moreOpened, setMoreOpened] = useState(false);
|
const [moreOpened, setMoreOpened] = useState(false);
|
||||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||||
@ -254,7 +255,6 @@ export const EpisodeLine = ({
|
|||||||
quality="low"
|
quality="low"
|
||||||
alt=""
|
alt=""
|
||||||
gradient={false}
|
gradient={false}
|
||||||
hideLoad={false}
|
|
||||||
layout={{
|
layout={{
|
||||||
width: percent(18),
|
width: percent(18),
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
@ -293,48 +293,36 @@ export const EpisodeLine = ({
|
|||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Skeleton>
|
{/* biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P */}
|
||||||
{isLoading || (
|
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
|
||||||
// biome-ignore lint/a11y/useValidAriaValues: simply use H6 for the style but keep a P
|
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
|
||||||
<H6 aria-level={undefined} {...css([{ flexShrink: 1 }, "title"])}>
|
</H6>
|
||||||
{[displayNumber, name ?? t("show.episodeNoMetadata")].join(" · ")}
|
|
||||||
</H6>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||||
<Skeleton>
|
<SubP>
|
||||||
{isLoading || (
|
{[
|
||||||
<SubP>
|
// @ts-ignore Source https://www.i18next.com/translation-function/formatting#datetime
|
||||||
{/* Source https://www.i18next.com/translation-function/formatting#datetime */}
|
releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
|
||||||
{[
|
displayRuntime(runtime),
|
||||||
releaseDate ? t("{{val, datetime}}", { val: releaseDate }) : null,
|
]
|
||||||
displayRuntime(runtime),
|
.filter((item) => item != null)
|
||||||
]
|
.join(" · ")}
|
||||||
.filter((item) => item != null)
|
</SubP>
|
||||||
.join(" · ")}
|
<EpisodesContext
|
||||||
</SubP>
|
slug={slug}
|
||||||
)}
|
showSlug={showSlug}
|
||||||
</Skeleton>
|
status={watchedStatus}
|
||||||
{slug && watchedStatus !== undefined && (
|
isOpen={moreOpened}
|
||||||
<EpisodesContext
|
setOpen={(v) => setMoreOpened(v)}
|
||||||
slug={slug}
|
{...css([
|
||||||
showSlug={showSlug}
|
"more",
|
||||||
status={watchedStatus}
|
{ display: "flex", marginLeft: ts(3) },
|
||||||
isOpen={moreOpened}
|
Platform.OS === "web" && moreOpened && { display: important("flex") },
|
||||||
setOpen={(v) => setMoreOpened(v)}
|
])}
|
||||||
{...css([
|
/>
|
||||||
"more",
|
|
||||||
{ display: "flex", marginLeft: ts(3) },
|
|
||||||
Platform.OS === "web" && moreOpened && { display: important("flex") },
|
|
||||||
])}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View {...css({ flexDirection: "row" })}>
|
<View {...css({ flexDirection: "row", justifyContent: "space-between" })}>
|
||||||
<Skeleton>
|
<P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>
|
||||||
{isLoading || <P numberOfLines={descriptionExpanded ? undefined : 3}>{overview}</P>}
|
|
||||||
</Skeleton>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
{...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
|
{...css(["more", Platform.OS !== "web" && { opacity: 1 }])}
|
||||||
icon={descriptionExpanded ? ExpandLess : ExpandMore}
|
icon={descriptionExpanded ? ExpandLess : ExpandMore}
|
||||||
@ -349,6 +337,45 @@ export const EpisodeLine = ({
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
EpisodeLine.Loader = (props: Stylable) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image.Loader
|
||||||
|
layout={{
|
||||||
|
width: percent(18),
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
}}
|
||||||
|
{...css({ flexShrink: 0, m: ts(1) })}
|
||||||
|
/>
|
||||||
|
<View {...css({ flexGrow: 1, flexShrink: 1, m: ts(1) })}>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Skeleton {...css({ width: percent(30) })} />
|
||||||
|
<Skeleton {...css({ width: percent(15) })} />
|
||||||
|
</View>
|
||||||
|
<Skeleton />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
EpisodeLine.layout = {
|
EpisodeLine.layout = {
|
||||||
numColumns: 1,
|
numColumns: 1,
|
||||||
size: 100,
|
size: 100,
|
||||||
|
@ -26,7 +26,7 @@ import {
|
|||||||
SeasonP,
|
SeasonP,
|
||||||
useInfiniteFetch,
|
useInfiniteFetch,
|
||||||
} from "@kyoo/models";
|
} from "@kyoo/models";
|
||||||
import { H6, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
|
import { H2, HR, IconButton, Menu, P, Skeleton, tooltip, ts, usePageStyle } from "@kyoo/primitives";
|
||||||
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
|
import MenuIcon from "@material-symbols/svg-400/rounded/menu-fill.svg";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -38,14 +38,12 @@ import { EpisodeLine, episodeDisplayNumber } from "./episode";
|
|||||||
type SeasonProcessed = Season & { href: string };
|
type SeasonProcessed = Season & { href: string };
|
||||||
|
|
||||||
export const SeasonHeader = ({
|
export const SeasonHeader = ({
|
||||||
isLoading,
|
|
||||||
seasonNumber,
|
seasonNumber,
|
||||||
name,
|
name,
|
||||||
seasons,
|
seasons,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
seasonNumber: number;
|
||||||
seasonNumber?: number;
|
name: string | null;
|
||||||
name?: string;
|
|
||||||
seasons?: SeasonProcessed[];
|
seasons?: SeasonProcessed[];
|
||||||
}) => {
|
}) => {
|
||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
@ -63,21 +61,20 @@ export const SeasonHeader = ({
|
|||||||
fontSize: rem(1.5),
|
fontSize: rem(1.5),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isLoading ? <Skeleton variant="filltext" /> : seasonNumber}
|
{seasonNumber}
|
||||||
</P>
|
</P>
|
||||||
<H6
|
<H2 {...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}>
|
||||||
aria-level={2}
|
{name ?? t("show.season", { number: seasonNumber })}
|
||||||
{...css({ marginX: ts(1), fontSize: rem(1.5), flexGrow: 1, flexShrink: 1 })}
|
</H2>
|
||||||
>
|
|
||||||
{isLoading ? <Skeleton /> : name}
|
|
||||||
</H6>
|
|
||||||
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
|
<Menu Trigger={IconButton} icon={MenuIcon} {...tooltip(t("show.jumpToSeason"))}>
|
||||||
{seasons
|
{seasons
|
||||||
?.filter((x) => x.episodesCount > 0)
|
?.filter((x) => x.episodesCount > 0)
|
||||||
.map((x) => (
|
.map((x) => (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
key={x.seasonNumber}
|
key={x.seasonNumber}
|
||||||
label={`${x.seasonNumber}: ${x.name} (${x.episodesCount})`}
|
label={`${x.seasonNumber}: ${
|
||||||
|
x.name ?? t("show.season", { number: x.seasonNumber })
|
||||||
|
} (${x.episodesCount})`}
|
||||||
href={x.href}
|
href={x.href}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -88,6 +85,31 @@ export const SeasonHeader = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
SeasonHeader.Loader = () => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View {...css({ flexDirection: "row", marginX: ts(1), justifyContent: "space-between" })}>
|
||||||
|
<View {...css({ flexDirection: "row", alignItems: "center" })}>
|
||||||
|
<Skeleton
|
||||||
|
variant="custom"
|
||||||
|
{...css({
|
||||||
|
width: rem(4),
|
||||||
|
flexShrink: 0,
|
||||||
|
marginX: ts(1),
|
||||||
|
height: rem(1.5),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Skeleton {...css({ marginX: ts(1), width: rem(12), height: rem(2) })} />
|
||||||
|
</View>
|
||||||
|
<IconButton icon={MenuIcon} disabled />
|
||||||
|
</View>
|
||||||
|
<HR />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
|
SeasonHeader.query = (slug: string): QueryIdentifier<Season, SeasonProcessed> => ({
|
||||||
parser: SeasonP,
|
parser: SeasonP,
|
||||||
path: ["show", slug, "seasons"],
|
path: ["show", slug, "seasons"],
|
||||||
@ -128,34 +150,39 @@ export const EpisodeList = <Props,>({
|
|||||||
divider
|
divider
|
||||||
Header={Header}
|
Header={Header}
|
||||||
headerProps={headerProps}
|
headerProps={headerProps}
|
||||||
getItemType={(item) => (item.firstOfSeason ? "withHeader" : "normal")}
|
getItemType={(item) => (!item || item.firstOfSeason ? "withHeader" : "normal")}
|
||||||
contentContainerStyle={pageStyle}
|
contentContainerStyle={pageStyle}
|
||||||
>
|
placeholderCount={5}
|
||||||
{(item) => {
|
Render={({ item }) => {
|
||||||
const sea = item?.firstOfSeason
|
const sea = item?.firstOfSeason
|
||||||
? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
|
? seasons?.find((x) => x.seasonNumber === item.seasonNumber)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{item.firstOfSeason && (
|
{item.firstOfSeason &&
|
||||||
<SeasonHeader
|
(sea ? (
|
||||||
isLoading={!sea}
|
<SeasonHeader name={sea.name} seasonNumber={sea.seasonNumber} seasons={seasons} />
|
||||||
name={sea?.name}
|
) : (
|
||||||
seasonNumber={sea?.seasonNumber}
|
<SeasonHeader.Loader />
|
||||||
seasons={seasons}
|
))}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<EpisodeLine
|
<EpisodeLine
|
||||||
{...item}
|
{...item}
|
||||||
|
// Don't display "Go to show"
|
||||||
showSlug={null}
|
showSlug={null}
|
||||||
displayNumber={item.isLoading ? undefined! : episodeDisplayNumber(item)!}
|
displayNumber={episodeDisplayNumber(item)}
|
||||||
watchedPercent={item.watchStatus?.watchedPercent ?? null}
|
watchedPercent={item.watchStatus?.watchedPercent ?? null}
|
||||||
watchedStatus={item.watchStatus?.status ?? null}
|
watchedStatus={item.watchStatus?.status ?? null}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</InfiniteFetch>
|
Loader={({ index }) => (
|
||||||
|
<>
|
||||||
|
{index === 0 && <SeasonHeader.Loader />}
|
||||||
|
<EpisodeLine.Loader />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -79,12 +79,11 @@ export const ShowWatchStatusCard = ({ watchedPercent, status, nextEpisode }: Sho
|
|||||||
>
|
>
|
||||||
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
|
<H2 {...css({ marginLeft: ts(2) })}>{t("show.nextUp")}</H2>
|
||||||
<EpisodeLine
|
<EpisodeLine
|
||||||
isLoading={false}
|
|
||||||
{...nextEpisode}
|
{...nextEpisode}
|
||||||
showSlug={null}
|
showSlug={null}
|
||||||
watchedPercent={watchedPercent || null}
|
watchedPercent={watchedPercent || null}
|
||||||
watchedStatus={status || null}
|
watchedStatus={status || null}
|
||||||
displayNumber={episodeDisplayNumber(nextEpisode, "???")!}
|
displayNumber={episodeDisplayNumber(nextEpisode)}
|
||||||
onHoverIn={() => setFocus(true)}
|
onHoverIn={() => setFocus(true)}
|
||||||
onHoverOut={() => setFocus(false)}
|
onHoverOut={() => setFocus(false)}
|
||||||
onFocus={() => setFocus(true)}
|
onFocus={() => setFocus(true)}
|
||||||
|
@ -195,6 +195,7 @@ const downloadIcon = (status: State["status"]) => {
|
|||||||
return Downloading;
|
return Downloading;
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
return ErrorIcon;
|
return ErrorIcon;
|
||||||
|
case "PENDING":
|
||||||
case "PAUSED":
|
case "PAUSED":
|
||||||
case "STOPPED":
|
case "STOPPED":
|
||||||
return NotStarted;
|
return NotStarted;
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Page, type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
||||||
import { HR, useBreakpointMap } from "@kyoo/primitives";
|
import { HR, useBreakpointMap } from "@kyoo/primitives";
|
||||||
import { type ContentStyle, FlashList } from "@shopify/flash-list";
|
import { type ContentStyle, FlashList } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
@ -30,7 +30,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { FlatList, View, type ViewStyle } from "react-native";
|
import { FlatList, View, type ViewStyle } from "react-native";
|
||||||
import { ErrorView } from "./errors";
|
import { ErrorView } from "./errors";
|
||||||
import { EmptyView, type Layout, OfflineView, type WithLoading, addHeader } from "./fetch";
|
import { EmptyView, type Layout, OfflineView, addHeader } from "./fetch";
|
||||||
|
|
||||||
const emulateGap = (
|
const emulateGap = (
|
||||||
layout: "grid" | "vertical" | "horizontal",
|
layout: "grid" | "vertical" | "horizontal",
|
||||||
@ -65,7 +65,8 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
|
|||||||
query,
|
query,
|
||||||
placeholderCount = 2,
|
placeholderCount = 2,
|
||||||
incremental = false,
|
incremental = false,
|
||||||
children,
|
Render,
|
||||||
|
Loader,
|
||||||
layout,
|
layout,
|
||||||
empty,
|
empty,
|
||||||
divider = false,
|
divider = false,
|
||||||
@ -82,16 +83,14 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
|
|||||||
placeholderCount?: number;
|
placeholderCount?: number;
|
||||||
layout: Layout;
|
layout: Layout;
|
||||||
horizontal?: boolean;
|
horizontal?: boolean;
|
||||||
children: (
|
Render: (props: { item: Data; index: number }) => ReactElement | null;
|
||||||
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
Loader: (props: { index: number }) => ReactElement | null;
|
||||||
i: number,
|
|
||||||
) => ReactElement | null;
|
|
||||||
empty?: string | JSX.Element;
|
empty?: string | JSX.Element;
|
||||||
incremental?: boolean;
|
incremental?: boolean;
|
||||||
divider?: boolean | ComponentType;
|
divider?: boolean | ComponentType;
|
||||||
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
|
Header?: ComponentType<Props & { children: JSX.Element }> | ReactElement;
|
||||||
headerProps?: Props;
|
headerProps?: Props;
|
||||||
getItemType?: (item: WithLoading<Data>, index: number) => Kind;
|
getItemType?: (item: Data | null, index: number) => Kind;
|
||||||
getItemSize?: (kind: Kind) => number;
|
getItemSize?: (kind: Kind) => number;
|
||||||
fetchMore?: boolean;
|
fetchMore?: boolean;
|
||||||
nested?: boolean;
|
nested?: boolean;
|
||||||
@ -111,9 +110,7 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
|
|||||||
|
|
||||||
if (incremental) items ??= oldItems.current;
|
if (incremental) items ??= oldItems.current;
|
||||||
const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
|
const count = items ? numColumns - (items.length % numColumns) : placeholderCount;
|
||||||
const placeholders = [...Array(count === 0 ? numColumns : count)].map(
|
const placeholders = [...Array(count === 0 ? numColumns : count)].fill(null);
|
||||||
(_, i) => ({ id: `gen${i}`, isLoading: true }) as Data,
|
|
||||||
);
|
|
||||||
const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
|
const data = isFetching || !items ? [...(items || []), ...placeholders] : items;
|
||||||
|
|
||||||
const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList;
|
const List = nested ? (FlatList as unknown as typeof FlashList) : FlashList;
|
||||||
@ -137,12 +134,12 @@ export const InfiniteFetchList = <Data, Props, _, Kind extends number | string>(
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{children({ isLoading: false, ...item } as any, index)}
|
{item ? <Render index={index} item={item} /> : <Loader index={index} />}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
data={data}
|
data={data}
|
||||||
horizontal={layout.layout === "horizontal"}
|
horizontal={layout.layout === "horizontal"}
|
||||||
keyExtractor={(item: any) => item.id}
|
keyExtractor={(item: any, index) => (item ? item.id : index)}
|
||||||
numColumns={layout.layout === "horizontal" ? 1 : numColumns}
|
numColumns={layout.layout === "horizontal" ? 1 : numColumns}
|
||||||
estimatedItemSize={size}
|
estimatedItemSize={size}
|
||||||
onEndReached={fetchMore ? fetchNextPage : undefined}
|
onEndReached={fetchMore ? fetchNextPage : undefined}
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Page, type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
import { type QueryIdentifier, useInfiniteFetch } from "@kyoo/models";
|
||||||
import { HR } from "@kyoo/primitives";
|
import { HR } from "@kyoo/primitives";
|
||||||
import type { ContentStyle } from "@shopify/flash-list";
|
import type { ContentStyle } from "@shopify/flash-list";
|
||||||
import {
|
import {
|
||||||
@ -33,7 +33,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki";
|
import { type Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki";
|
||||||
import { ErrorView } from "./errors";
|
import { ErrorView } from "./errors";
|
||||||
import { EmptyView, type Layout, type WithLoading, addHeader } from "./fetch";
|
import { EmptyView, type Layout, addHeader } from "./fetch";
|
||||||
|
|
||||||
const InfiniteScroll = <Props,>({
|
const InfiniteScroll = <Props,>({
|
||||||
children,
|
children,
|
||||||
@ -145,7 +145,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
|
|||||||
query,
|
query,
|
||||||
incremental = false,
|
incremental = false,
|
||||||
placeholderCount = 2,
|
placeholderCount = 2,
|
||||||
children,
|
Render,
|
||||||
layout,
|
layout,
|
||||||
empty,
|
empty,
|
||||||
divider: Divider = false,
|
divider: Divider = false,
|
||||||
@ -154,21 +154,20 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
|
|||||||
getItemType,
|
getItemType,
|
||||||
getItemSize,
|
getItemSize,
|
||||||
nested,
|
nested,
|
||||||
|
Loader,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
query: ReturnType<typeof useInfiniteFetch<_, Data>>;
|
query: ReturnType<typeof useInfiniteFetch<_, Data>>;
|
||||||
incremental?: boolean;
|
incremental?: boolean;
|
||||||
placeholderCount?: number;
|
placeholderCount?: number;
|
||||||
layout: Layout;
|
layout: Layout;
|
||||||
children: (
|
Render: (props: { item: Data; index: number }) => ReactElement | null;
|
||||||
item: Data extends Page<infer Item> ? WithLoading<Item> : WithLoading<Data>,
|
Loader: (props: { index: number }) => ReactElement | null;
|
||||||
i: number,
|
|
||||||
) => ReactElement | null;
|
|
||||||
empty?: string | JSX.Element;
|
empty?: string | JSX.Element;
|
||||||
divider?: boolean | ComponentType;
|
divider?: boolean | ComponentType;
|
||||||
Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
|
Header?: ComponentType<{ children: JSX.Element } & HeaderProps> | ReactElement;
|
||||||
headerProps: HeaderProps;
|
headerProps: HeaderProps;
|
||||||
getItemType?: (item: WithLoading<Data>, index: number) => Kind;
|
getItemType?: (item: Data | null, index: number) => Kind;
|
||||||
getItemSize?: (kind: Kind) => number;
|
getItemSize?: (kind: Kind) => number;
|
||||||
fetchMore?: boolean;
|
fetchMore?: boolean;
|
||||||
contentContainerStyle?: ContentStyle;
|
contentContainerStyle?: ContentStyle;
|
||||||
@ -193,7 +192,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
|
|||||||
loader={[...Array(placeholderCount)].map((_, i) => (
|
loader={[...Array(placeholderCount)].map((_, i) => (
|
||||||
<Fragment key={i.toString()}>
|
<Fragment key={i.toString()}>
|
||||||
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
|
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
|
||||||
{children({ isLoading: true } as any, i)}
|
<Loader index={i} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
Header={Header}
|
Header={Header}
|
||||||
@ -203,7 +202,7 @@ export const InfiniteFetchList = <Data, _, HeaderProps, Kind extends number | st
|
|||||||
{(items ?? oldItems.current)?.map((item, i) => (
|
{(items ?? oldItems.current)?.map((item, i) => (
|
||||||
<Fragment key={(item as any).id}>
|
<Fragment key={(item as any).id}>
|
||||||
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
|
{Divider && i !== 0 && (Divider === true ? <HR /> : <Divider />)}
|
||||||
{children({ ...item, isLoading: false } as any, i)}
|
<Render item={item} index={i} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</InfiniteScroll>
|
</InfiniteScroll>
|
||||||
|
@ -75,13 +75,9 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
|
|||||||
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
|
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
|
||||||
placeholderCount={2}
|
placeholderCount={2}
|
||||||
empty={displayEmpty.current ? t("home.none") : undefined}
|
empty={displayEmpty.current ? t("home.none") : undefined}
|
||||||
>
|
Render={({ item }) => <ItemGrid {...itemMap(item)} />}
|
||||||
{(x, i) => {
|
Loader={ItemGrid.Loader}
|
||||||
// only display empty list if a loading as been displayed (not durring ssr)
|
/>
|
||||||
if (x.isLoading) displayEmpty.current = true;
|
|
||||||
return <ItemGrid key={x.id ?? i} {...itemMap(x)} />;
|
|
||||||
}}
|
|
||||||
</InfiniteFetchList>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -36,41 +36,43 @@ export const NewsList = () => {
|
|||||||
<InfiniteFetch
|
<InfiniteFetch
|
||||||
query={NewsList.query()}
|
query={NewsList.query()}
|
||||||
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
|
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
|
||||||
getItemType={(x, i) => (x.kind === "movie" || (x.isLoading && i % 2) ? "movie" : "episode")}
|
getItemType={(x, i) => (x?.kind === "movie" || (!x && i % 2) ? "movie" : "episode")}
|
||||||
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
|
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
|
||||||
empty={t("home.none")}
|
empty={t("home.none")}
|
||||||
>
|
Render={({ item }) => {
|
||||||
{(x, i) =>
|
if (item.kind === "episode") {
|
||||||
x.kind === "movie" || (x.isLoading && i % 2) ? (
|
return (
|
||||||
|
<EpisodeBox
|
||||||
|
slug={item.slug}
|
||||||
|
showSlug={item.show!.slug}
|
||||||
|
name={`${item.show!.name} ${episodeDisplayNumber(item)}`}
|
||||||
|
overview={item.name}
|
||||||
|
thumbnail={item.thumbnail}
|
||||||
|
href={item.href}
|
||||||
|
watchedPercent={item.watchStatus?.watchedPercent || null}
|
||||||
|
watchedStatus={item.watchStatus?.status || null}
|
||||||
|
// TODO: Move this into the ItemList (using getItemSize)
|
||||||
|
// @ts-expect-error This is a web only property
|
||||||
|
{...css({ gridColumnEnd: "span 2" })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<ItemGrid
|
<ItemGrid
|
||||||
isLoading={x.isLoading as any}
|
href={item.href}
|
||||||
href={x.href}
|
slug={item.slug}
|
||||||
slug={x.slug}
|
name={item.name!}
|
||||||
name={x.name!}
|
subtitle={getDisplayDate(item)}
|
||||||
subtitle={!x.isLoading ? getDisplayDate(x) : undefined}
|
poster={item.poster}
|
||||||
poster={x.poster}
|
watchStatus={item.watchStatus?.status || null}
|
||||||
watchStatus={x.watchStatus?.status || null}
|
watchPercent={item.watchStatus?.watchedPercent || null}
|
||||||
watchPercent={x.watchStatus?.watchedPercent || null}
|
unseenEpisodesCount={null}
|
||||||
type={"movie"}
|
type={"movie"}
|
||||||
/>
|
/>
|
||||||
) : (
|
);
|
||||||
<EpisodeBox
|
}}
|
||||||
isLoading={x.isLoading as any}
|
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
|
||||||
slug={x.slug}
|
/>
|
||||||
showSlug={x.kind === "episode" ? x.show!.slug : null}
|
|
||||||
name={x.kind === "episode" ? `${x.show!.name} ${episodeDisplayNumber(x)}` : undefined}
|
|
||||||
overview={x.name}
|
|
||||||
thumbnail={x.thumbnail}
|
|
||||||
href={x.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" })}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</InfiniteFetch>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -33,6 +33,7 @@ import {
|
|||||||
IconFab,
|
IconFab,
|
||||||
Link,
|
Link,
|
||||||
P,
|
P,
|
||||||
|
Poster,
|
||||||
PosterBackground,
|
PosterBackground,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SubP,
|
SubP,
|
||||||
@ -48,11 +49,10 @@ import { ScrollView, View } from "react-native";
|
|||||||
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
|
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
|
||||||
import { ItemGrid, ItemWatchStatus } from "../browse/grid";
|
import { ItemGrid, ItemWatchStatus } from "../browse/grid";
|
||||||
import { ItemContext } from "../components/context-menus";
|
import { ItemContext } from "../components/context-menus";
|
||||||
import type { Layout, WithLoading } from "../fetch";
|
import type { Layout } from "../fetch";
|
||||||
import { InfiniteFetch } from "../fetch-infinite";
|
import { InfiniteFetch } from "../fetch-infinite";
|
||||||
|
|
||||||
export const ItemDetails = ({
|
export const ItemDetails = ({
|
||||||
isLoading,
|
|
||||||
slug,
|
slug,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
@ -66,12 +66,12 @@ export const ItemDetails = ({
|
|||||||
watchStatus,
|
watchStatus,
|
||||||
unseenEpisodesCount,
|
unseenEpisodesCount,
|
||||||
...props
|
...props
|
||||||
}: WithLoading<{
|
}: {
|
||||||
slug: string;
|
slug: string;
|
||||||
type: "movie" | "show" | "collection";
|
type: "movie" | "show" | "collection";
|
||||||
name: string;
|
name: string;
|
||||||
tagline: string | null;
|
tagline: string | null;
|
||||||
subtitle: string;
|
subtitle: string | null;
|
||||||
poster: KyooImage | null;
|
poster: KyooImage | null;
|
||||||
genres: Genre[] | null;
|
genres: Genre[] | null;
|
||||||
overview: string | null;
|
overview: string | null;
|
||||||
@ -79,7 +79,7 @@ export const ItemDetails = ({
|
|||||||
playHref: string | null;
|
playHref: string | null;
|
||||||
watchStatus: WatchStatusV | null;
|
watchStatus: WatchStatusV | null;
|
||||||
unseenEpisodesCount: number | null;
|
unseenEpisodesCount: number | null;
|
||||||
}>) => {
|
}) => {
|
||||||
const [moreOpened, setMoreOpened] = useState(false);
|
const [moreOpened, setMoreOpened] = useState(false);
|
||||||
const { css } = useYoshiki("recommended-card");
|
const { css } = useYoshiki("recommended-card");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -124,7 +124,6 @@ export const ItemDetails = ({
|
|||||||
src={poster}
|
src={poster}
|
||||||
alt=""
|
alt=""
|
||||||
quality="low"
|
quality="low"
|
||||||
forcedLoading={isLoading}
|
|
||||||
layout={{ height: percent(100) }}
|
layout={{ height: percent(100) }}
|
||||||
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||||
>
|
>
|
||||||
@ -138,18 +137,8 @@ export const ItemDetails = ({
|
|||||||
p: ts(1),
|
p: ts(1),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Skeleton {...css({ width: percent(100) })}>
|
<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>{name}</P>
|
||||||
{isLoading || (
|
{subtitle && <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
|
||||||
<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>
|
|
||||||
{name}
|
|
||||||
</P>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
{(subtitle || isLoading) && (
|
|
||||||
<Skeleton {...css({ height: rem(0.8) })}>
|
|
||||||
{isLoading || <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
|
|
||||||
</Skeleton>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
|
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
|
||||||
</PosterBackground>
|
</PosterBackground>
|
||||||
@ -163,7 +152,7 @@ export const ItemDetails = ({
|
|||||||
alignContent: "flex-start",
|
alignContent: "flex-start",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{slug && type && type !== "collection" && watchStatus !== undefined && (
|
{type !== "collection" && (
|
||||||
<ItemContext
|
<ItemContext
|
||||||
type={type}
|
type={type}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
@ -173,18 +162,10 @@ export const ItemDetails = ({
|
|||||||
force
|
force
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(isLoading || tagline) && (
|
{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
|
||||||
<Skeleton {...css({ m: ts(1), marginVertical: ts(2) })}>
|
|
||||||
{isLoading || <P {...css({ p: ts(1) })}>{tagline}</P>}
|
|
||||||
</Skeleton>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<ScrollView {...css({ pX: ts(1) })}>
|
<ScrollView {...css({ pX: ts(1) })}>
|
||||||
<Skeleton lines={5} {...css({ height: rem(0.8) })}>
|
<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
|
||||||
{isLoading || (
|
|
||||||
<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
|
|
||||||
)}
|
|
||||||
</Skeleton>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
@ -209,9 +190,9 @@ export const ItemDetails = ({
|
|||||||
height: px(50),
|
height: px(50),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{(isLoading || genres) && (
|
{genres && (
|
||||||
<ScrollView horizontal contentContainerStyle={{ alignItems: "center" }}>
|
<ScrollView horizontal contentContainerStyle={{ alignItems: "center" }}>
|
||||||
{(genres || [...Array(3)])?.map((x, i) => (
|
{genres.map((x, i) => (
|
||||||
<Chip key={x ?? i} label={x} size="small" {...css({ mX: ts(0.5) })} />
|
<Chip key={x ?? i} label={x} size="small" {...css({ mX: ts(0.5) })} />
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@ -231,6 +212,65 @@ export const ItemDetails = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ItemDetails.Loader = (props: object) => {
|
||||||
|
const { css } = useYoshiki();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
{...css(
|
||||||
|
{
|
||||||
|
height: ItemDetails.layout.size,
|
||||||
|
flexDirection: "row",
|
||||||
|
bg: (theme) => theme.variant.background,
|
||||||
|
borderRadius: calc(px(imageBorderRadius), "+", ts(0.25)),
|
||||||
|
overflow: "hidden",
|
||||||
|
borderColor: (theme) => theme.background,
|
||||||
|
borderWidth: ts(0.25),
|
||||||
|
borderStyle: "solid",
|
||||||
|
},
|
||||||
|
props,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Poster.Loader
|
||||||
|
layout={{ height: percent(100) }}
|
||||||
|
{...css({ borderTopRightRadius: 0, borderBottomRightRadius: 0 })}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
bg: (theme) => theme.darkOverlay,
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
p: ts(1),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Skeleton {...css({ width: percent(100) })} />
|
||||||
|
<Skeleton {...css({ height: rem(0.8) })} />
|
||||||
|
</View>
|
||||||
|
</Poster.Loader>
|
||||||
|
<View {...css({ flexShrink: 1, flexGrow: 1 })}>
|
||||||
|
<View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}>
|
||||||
|
<Skeleton {...css({ marginVertical: ts(2) })} />
|
||||||
|
<Skeleton lines={5} {...css({ height: rem(0.8) })} />
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
{...css({
|
||||||
|
bg: (theme) => theme.themeOverlay,
|
||||||
|
pX: 4,
|
||||||
|
height: px(50),
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
|
||||||
|
<Chip.Loader size="small" {...css({ mX: ts(0.5) })} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
ItemDetails.layout = {
|
ItemDetails.layout = {
|
||||||
size: ts(36),
|
size: ts(36),
|
||||||
numColumns: { xs: 1, md: 2, xl: 3 },
|
numColumns: { xs: 1, md: 2, xl: 3 },
|
||||||
@ -252,29 +292,28 @@ export const Recommended = () => {
|
|||||||
fetchMore={false}
|
fetchMore={false}
|
||||||
nested
|
nested
|
||||||
contentContainerStyle={{ padding: 0, paddingHorizontal: 0 }}
|
contentContainerStyle={{ padding: 0, paddingHorizontal: 0 }}
|
||||||
>
|
Render={({ item }) => (
|
||||||
{(x) => (
|
|
||||||
<ItemDetails
|
<ItemDetails
|
||||||
isLoading={x.isLoading as any}
|
slug={item.slug}
|
||||||
slug={x.slug}
|
type={item.kind}
|
||||||
type={x.kind}
|
name={item.name}
|
||||||
name={x.name}
|
tagline={"tagline" in item ? item.tagline : null}
|
||||||
tagline={"tagline" in x ? x.tagline : null}
|
overview={item.overview}
|
||||||
overview={x.overview}
|
poster={item.poster}
|
||||||
poster={x.poster}
|
subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
|
||||||
subtitle={x.kind !== "collection" && !x.isLoading ? getDisplayDate(x) : undefined}
|
genres={"genres" in item ? item.genres : null}
|
||||||
genres={"genres" in x ? x.genres : null}
|
href={item.href}
|
||||||
href={x.href}
|
playHref={item.kind !== "collection" ? item.playHref : null}
|
||||||
playHref={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined}
|
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
|
||||||
watchStatus={
|
|
||||||
!x.isLoading && x.kind !== "collection" ? x.watchStatus?.status ?? null : null
|
|
||||||
}
|
|
||||||
unseenEpisodesCount={
|
unseenEpisodesCount={
|
||||||
x.kind === "show" ? x.watchStatus?.unseenEpisodesCount ?? x.episodesCount! : null
|
item.kind === "show"
|
||||||
|
? item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</InfiniteFetch>
|
Loader={ItemDetails.Loader}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -41,9 +41,9 @@ export const VerticalRecommended = () => {
|
|||||||
layout={{ ...ItemList.layout, layout: "vertical" }}
|
layout={{ ...ItemList.layout, layout: "vertical" }}
|
||||||
fetchMore={false}
|
fetchMore={false}
|
||||||
nested
|
nested
|
||||||
>
|
Render={({ item }) => <ItemList {...itemMap(item)} />}
|
||||||
{(x, i) => <ItemList key={x.id ?? i} {...itemMap(x)} />}
|
Loader={() => <ItemList.Loader />}
|
||||||
</InfiniteFetch>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -39,55 +39,10 @@ export const WatchlistList = () => {
|
|||||||
const { css } = useYoshiki();
|
const { css } = useYoshiki();
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
|
|
||||||
return (
|
if (!account) {
|
||||||
<>
|
return (
|
||||||
<Header title={t("home.watchlist")} />
|
<>
|
||||||
{account ? (
|
<Header title={t("home.watchlist")} />
|
||||||
<InfiniteFetch
|
|
||||||
query={WatchlistList.query()}
|
|
||||||
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
|
|
||||||
getItemType={(x, i) =>
|
|
||||||
(x.kind === "show" && x.watchStatus?.nextEpisode) || (x.isLoading && i % 2)
|
|
||||||
? "episode"
|
|
||||||
: "item"
|
|
||||||
}
|
|
||||||
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
|
|
||||||
empty={t("home.none")}
|
|
||||||
>
|
|
||||||
{(x, i) => {
|
|
||||||
const episode = x.kind === "show" ? x.watchStatus?.nextEpisode : null;
|
|
||||||
return (x.kind === "show" && x.watchStatus?.nextEpisode) || (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" })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ItemGrid
|
|
||||||
isLoading={x.isLoading as any}
|
|
||||||
href={x.href}
|
|
||||||
slug={x.slug}
|
|
||||||
name={x.name!}
|
|
||||||
subtitle={!x.isLoading ? getDisplayDate(x) : undefined}
|
|
||||||
poster={x.poster}
|
|
||||||
watchStatus={x.watchStatus?.status || null}
|
|
||||||
watchPercent={x.watchStatus?.watchedPercent || null}
|
|
||||||
unseenEpisodesCount={x.kind === "show" ? x.watchStatus?.unseenEpisodesCount : null}
|
|
||||||
type={x.kind}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</InfiniteFetch>
|
|
||||||
) : (
|
|
||||||
<View {...css({ justifyContent: "center", alignItems: "center" })}>
|
<View {...css({ justifyContent: "center", alignItems: "center" })}>
|
||||||
<P>{t("home.watchlistLogin")}</P>
|
<P>{t("home.watchlistLogin")}</P>
|
||||||
<Button
|
<Button
|
||||||
@ -96,7 +51,58 @@ export const WatchlistList = () => {
|
|||||||
{...css({ minWidth: ts(24), margin: ts(2) })}
|
{...css({ minWidth: ts(24), margin: ts(2) })}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header title={t("home.watchlist")} />
|
||||||
|
<InfiniteFetch
|
||||||
|
query={WatchlistList.query()}
|
||||||
|
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
|
||||||
|
getItemType={(x, i) =>
|
||||||
|
(x?.kind === "show" && x.watchStatus?.nextEpisode) || (!x && i % 2) ? "episode" : "item"
|
||||||
|
}
|
||||||
|
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
|
||||||
|
empty={t("home.none")}
|
||||||
|
Render={({ item }) => {
|
||||||
|
const episode = item.kind === "show" ? item.watchStatus?.nextEpisode : null;
|
||||||
|
if (episode) {
|
||||||
|
return (
|
||||||
|
<EpisodeBox
|
||||||
|
slug={episode.slug}
|
||||||
|
showSlug={item.slug}
|
||||||
|
name={`${item.name} ${episodeDisplayNumber(episode)}`}
|
||||||
|
overview={episode.name}
|
||||||
|
thumbnail={episode.thumbnail ?? item.thumbnail}
|
||||||
|
href={episode.href}
|
||||||
|
watchedPercent={item.watchStatus?.watchedPercent || null}
|
||||||
|
watchedStatus={item.watchStatus?.status || null}
|
||||||
|
// TODO: Move this into the ItemList (using getItemSize)
|
||||||
|
// @ts-expect-error This is a web only property
|
||||||
|
{...css({ gridColumnEnd: "span 2" })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ItemGrid
|
||||||
|
href={item.href}
|
||||||
|
slug={item.slug}
|
||||||
|
name={item.name!}
|
||||||
|
subtitle={getDisplayDate(item)}
|
||||||
|
poster={item.poster}
|
||||||
|
watchStatus={item.watchStatus?.status || null}
|
||||||
|
watchPercent={item.watchStatus?.watchedPercent || null}
|
||||||
|
unseenEpisodesCount={
|
||||||
|
(item.kind === "show" && item.watchStatus?.unseenEpisodesCount) || null
|
||||||
|
}
|
||||||
|
type={item.kind}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -53,7 +53,7 @@ const mapData = (
|
|||||||
if (!data) return { isLoading: true };
|
if (!data) return { isLoading: true };
|
||||||
return {
|
return {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data, "")} ${data.name}`,
|
name: data.type === "movie" ? data.name : `${episodeDisplayNumber(data)} ${data.name}`,
|
||||||
showName: data.type === "movie" ? data.name! : data.show!.name,
|
showName: data.type === "movie" ? data.name! : data.show!.name,
|
||||||
poster: data.type === "movie" ? data.poster : data.show!.poster,
|
poster: data.type === "movie" ? data.poster : data.show!.poster,
|
||||||
subtitles: info?.subtitles,
|
subtitles: info?.subtitles,
|
||||||
|
@ -77,9 +77,9 @@ export const SearchPage: QueryPage<{ q?: string }> = ({ q }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
contentContainerStyle={pageStyle}
|
contentContainerStyle={pageStyle}
|
||||||
>
|
Render={({ item }) => <LayoutComponent {...itemMap(item)} />}
|
||||||
{(item) => <LayoutComponent {...itemMap(item)} />}
|
Loader={LayoutComponent.Loader}
|
||||||
</InfiniteFetch>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -39,7 +39,8 @@
|
|||||||
"droped": "Mark as dropped",
|
"droped": "Mark as dropped",
|
||||||
"null": "Mark as not seen"
|
"null": "Mark as not seen"
|
||||||
},
|
},
|
||||||
"nextUp": "Next up"
|
"nextUp": "Next up",
|
||||||
|
"season": "Season {{number}}"
|
||||||
},
|
},
|
||||||
"browse": {
|
"browse": {
|
||||||
"sortby": "Sort by {{key}}",
|
"sortby": "Sort by {{key}}",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user