Add list view on browse

This commit is contained in:
Zoe Roux 2022-12-11 16:32:57 +09:00
parent 3b29e1a87a
commit be6551888e
17 changed files with 331 additions and 124 deletions

View File

@ -31,7 +31,7 @@
"react-native-safe-area-context": "4.4.1",
"react-native-screens": "~3.18.0",
"react-native-svg": "13.4.0",
"yoshiki": "0.2.7"
"yoshiki": "0.2.9"
},
"devDependencies": {
"@babel/core": "^7.19.3",

View File

@ -41,7 +41,7 @@
"react-native-web": "^0.18.10",
"solito": "^2.0.5",
"superjson": "^1.11.0",
"yoshiki": "0.2.7",
"yoshiki": "0.2.9",
"zod": "^3.19.1"
},
"devDependencies": {

View File

@ -70,6 +70,11 @@ const GlobalCssTheme = () => {
#__next {
height: 100vh;
}
.infinite-scroll-component__outerdiv {
width: 100%;
height: 100%;
}
`}</style>
<WebTooltip theme={theme} />
<SkeletonCss />

View File

@ -0,0 +1,47 @@
/*
* Kyoo - A portable and vast media library solution.
* Copyright (c) Kyoo.
*
* See AUTHORS.md and LICENSE file in the project root for full license information.
*
* Kyoo is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Kyoo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { motify } from "moti";
import { Component, ComponentType, FunctionComponent } from "react";
import { Poster } from "./image";
const getDisplayName = (Cmp: ComponentType<any>) => {
return Cmp.displayName || Cmp.name || "Component";
};
const asClass = <Props,>(Cmp: FunctionComponent<Props>) => {
// TODO: ensure that every props is given at least once.
return class AsClass extends Component<Partial<Props> & { forward?: Partial<Props> }> {
static displayName = `WithClass(${getDisplayName(Cmp)})`;
constructor(props: Partial<Props> & { forward?: Partial<Props> }) {
super(props);
}
render() {
// @ts-ignore See todo above
return <Cmp {...this.props} {...this.props.forward} />;
}
};
};
export const Animated = {
Poster: motify(asClass(Poster))(),
};

View File

@ -18,7 +18,7 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { useState } from "react";
import { ComponentType, ReactNode, useState } from "react";
import {
View,
Image as Img,
@ -26,10 +26,14 @@ import {
ImageStyle,
Platform,
ImageProps,
ViewProps,
ViewStyle,
} from "react-native";
import { useYoshiki } from "yoshiki/native";
import { YoshikiStyle } from "yoshiki/dist/type";
import { percent, useYoshiki } from "yoshiki/native";
import { StyleList, YoshikiStyle } from "yoshiki/dist/type";
import { Skeleton } from "./skeleton";
import { LinearGradient, LinearGradientProps } from "expo-linear-gradient";
import { alpha, ContrastArea } from "./themes";
type YoshikiEnhanced<Style> = Style extends any
? {
@ -42,22 +46,21 @@ type WithLoading<T> = (T & { isLoading?: boolean }) | (Partial<T> & { isLoading:
type Props = WithLoading<{
src?: string | ImageSourcePropType | null;
alt?: string;
fallback?: string | ImageSourcePropType;
}>;
type ImageLayout = YoshikiEnhanced<
| { width: ViewStyle["width"]; height: ViewStyle["height"] }
| { width: ViewStyle["width"]; aspectRatio: ViewStyle["aspectRatio"] }
| { height: ViewStyle["height"]; aspectRatio: ViewStyle["aspectRatio"] }
>;
export const Image = ({
src,
alt,
isLoading: forcedLoading = false,
layout,
...props
}: Props & { style?: ImageStyle } & {
layout: YoshikiEnhanced<
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
>;
}) => {
}: Props & { style?: ViewStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki();
const [state, setState] = useState<"loading" | "errored" | "finished">(
src ? "loading" : "errored",
@ -71,11 +74,11 @@ export const Image = ({
setOldSource(src);
}
const border = { borderRadius: 6 } satisfies ImageStyle;
const border = { borderRadius: 6 } satisfies ViewStyle;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border])} />;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
if (!src || state === "errored")
return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border])} />;
return <View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />;
const nativeProps = Platform.select<ImageProps>({
web: {
@ -85,22 +88,20 @@ export const Image = ({
});
return (
<Skeleton variant="custom" show={state === "loading"} {...css([layout, border])}>
<Skeleton variant="custom" show={state === "loading"} {...css([layout, border], props)}>
<Img
source={typeof src === "string" ? { uri: src } : src}
accessibilityLabel={alt}
onLoad={() => setState("finished")}
onError={() => setState("errored")}
{...nativeProps}
{...css(
[
{
resizeMode: "cover",
},
layout,
],
props,
)}
{...css([
{
width: percent(100),
height: percent(100),
resizeMode: "cover",
},
])}
/>
</Skeleton>
);
@ -111,8 +112,90 @@ export const Poster = ({
isLoading = false,
layout,
...props
}: Props & { style?: ImageStyle } & {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}: Props & { style?: ViewStyle } & {
layout: YoshikiEnhanced<{ width: ViewStyle["width"] } | { height: ViewStyle["height"] }>;
}) => (
<Image isLoading={isLoading} alt={alt} layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />
);
export const ImageBackground = <AsProps = ViewProps,>({
src,
alt,
gradient = true,
as,
children,
containerStyle,
imageStyle,
isLoading,
...asProps
}: {
as?: ComponentType<AsProps>;
gradient?: Partial<LinearGradientProps> | boolean;
children: ReactNode;
containerStyle?: StyleList<ViewStyle>;
imageStyle?: StyleList<ImageStyle>;
} & AsProps &
Props) => {
const [isErrored, setErrored] = useState(false);
const nativeProps = Platform.select<ImageProps>({
web: {
defaultSource: typeof src === "string" ? { uri: src! } : Array.isArray(src) ? src[0] : src!,
},
default: {},
});
const Container = as ?? View;
return (
<ContrastArea contrastText>
{({ css, theme }) => (
<Container {...(asProps as AsProps)}>
<View
{...css([
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
bg: (theme) => theme.background,
},
containerStyle,
])}
>
{src && !isErrored && (
<Img
source={typeof src === "string" ? { uri: src } : src}
accessibilityLabel={alt}
onError={() => setErrored(true)}
{...nativeProps}
{...css([
{ width: percent(100), height: percent(100), resizeMode: "cover" },
imageStyle,
])}
/>
)}
{gradient && (
<LinearGradient
start={{ x: 0, y: 0.25 }}
end={{ x: 0, y: 1 }}
colors={["transparent", alpha(theme.colors.black, 0.6)]}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
typeof gradient === "object" ? gradient : undefined,
)}
/>
)}
</View>
{children}
</Container>
)}
</ContrastArea>
);
};

View File

@ -28,6 +28,8 @@ export * from "./image";
export * from "./skeleton";
export * from "./tooltip";
export * from "./animated";
export * from "./utils/breakpoints";
export * from "./utils/nojs";

View File

@ -21,7 +21,6 @@
import { ComponentType, ReactNode } from "react";
import {
Platform,
Pressable,
TextProps,
TouchableOpacity,
TouchableNativeFeedback,
@ -29,7 +28,7 @@ import {
ViewProps,
} from "react-native";
import { LinkCore, TextLink } from "solito/link";
import { useYoshiki } from "yoshiki/native";
import { useYoshiki, Pressable } from "yoshiki/native";
export const A = ({
href,
@ -55,7 +54,20 @@ export const A = ({
);
};
export const Link = ({ href, children, ...props }: ViewProps & { href: string }) => {
export const Link = ({
href,
children,
...props
}: ViewProps & {
href: string;
onFocus?: () => void;
onBlur?: () => void;
onPressIn?: () => void;
onPressOut?: () => void;
}) => {
const { onBlur, onFocus, onPressIn, onPressOut, ...noFocusProps } = props;
const focusProps = { onBlur, onFocus, onPressIn, onPressOut };
return (
<LinkCore
href={href}
@ -66,7 +78,7 @@ export const Link = ({ href, children, ...props }: ViewProps & { href: string })
default: Pressable,
})}
componentProps={Platform.select<object>({
android: { useForeground: true },
android: { useForeground: true, ...focusProps },
default: props,
})}
>

View File

@ -69,7 +69,7 @@ export const Skeleton = ({
borderRadius: px(6),
},
variant === "text" && {
margin: px(2),
margin: rem(1),
width: percent(75),
height: rem(1.2),
},

View File

@ -68,5 +68,6 @@ export const H3 = styleText(EH3, "header");
export const H4 = styleText(EH4, "header");
export const H5 = styleText(EH5, "header");
export const H6 = styleText(EH6, "header");
export const Heading = styleText(EP, "header");
export const P = styleText(EP);
export const SubP = styleText(EP, "sub");

View File

@ -83,7 +83,7 @@ export const catppuccin: ThemeBuilder = {
blue: "#89b4fa",
yellow: "#f9e2af",
black: "#11111b",
white: "#cdd6f4",
white: "#f5f0f8",
},
},
};

View File

@ -20,7 +20,8 @@
import { ReactNode } from "react";
import { Property } from "csstype";
import { Theme, ThemeProvider, useTheme } from "yoshiki";
import { Theme, ThemeProvider } from "yoshiki";
import { useTheme, useYoshiki } from "yoshiki/native";
import "yoshiki";
import { catppuccin } from "./catppuccin";
@ -57,7 +58,9 @@ type Variant = {
declare module "yoshiki" {
// TODO: Add specifics colors
export interface Theme extends ThemeSettings, Mode, Variant {}
export interface Theme extends ThemeSettings, Mode, Variant {
builder: ThemeBuilder;
}
}
export type { Theme } from "yoshiki";
@ -70,7 +73,7 @@ export const selectMode = (theme: ThemeBuilder, mode: "light" | "dark"): Theme =
const { light, dark, ...options } = theme;
const value = mode === "light" ? light : dark;
const { default: def, ...modeOpt } = value;
return { ...options, ...modeOpt, ...def, variant: value.variant };
return { ...options, ...modeOpt, ...def, variant: value.variant, builder: theme };
};
export const switchVariant = (theme: Theme) => {
@ -88,12 +91,56 @@ export const switchVariant = (theme: Theme) => {
};
};
export const SwitchVariant = ({ children }: { children?: JSX.Element | JSX.Element[] }) => {
const theme = useTheme();
return <ThemeProvider theme={switchVariant(theme)}>{children}</ThemeProvider>;
};
export const ThemeSelector = ({ children }: { children: ReactNode }) => {
return <ThemeProvider theme={selectMode(catppuccin, "light")}>{children}</ThemeProvider>;
};
type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T;
const YoshikiProvider = ({ children }: { children: YoshikiFunc<ReactNode> }) => {
const yoshiki = useYoshiki();
return <>{children(yoshiki)}</>;
};
export const SwitchVariant = ({ children }: { children: ReactNode | YoshikiFunc<ReactNode> }) => {
const theme = useTheme();
return (
<ThemeProvider theme={switchVariant(theme)}>
{typeof children === "function" ? <YoshikiProvider>{children}</YoshikiProvider> : children}
</ThemeProvider>
);
};
export const ContrastArea = ({
children,
mode = "dark",
contrastText,
}: {
children: ReactNode | YoshikiFunc<ReactNode>;
mode?: "light" | "dark";
contrastText?: boolean;
}) => {
const oldTheme = useTheme();
const theme = selectMode(oldTheme.builder, mode);
return (
<ThemeProvider
theme={
contrastText
? {
...theme,
heading: mode === "light" ? theme.colors.black : theme.colors.white,
paragraph: theme.heading,
}
: theme
}
>
{typeof children === "function" ? <YoshikiProvider>{children}</YoshikiProvider> : children}
</ThemeProvider>
);
};
export const alpha = (color: Property.Color, alpha: number) => {
return color + (alpha * 255).toString(16);
};

View File

@ -46,6 +46,7 @@ export const WebTooltip = ({ theme }: { theme: Theme }) => {
top: 100%;
left: 50%;
transform: translate(-50%);
z-index: 999;
margin-top: 8px;
border-radius: 5px;

View File

@ -34,7 +34,9 @@ import { ItemGrid } from "./grid";
import { ItemList } from "./list";
import { SortBy, SortOrd, Layout } from "./types";
const itemMap = (item: WithLoading<LibraryItem>): WithLoading<ComponentProps<typeof ItemGrid>> => {
const itemMap = (
item: WithLoading<LibraryItem>,
): WithLoading<ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList>> => {
if (item.isLoading) return item;
let href;
@ -48,6 +50,7 @@ const itemMap = (item: WithLoading<LibraryItem>): WithLoading<ComponentProps<typ
subtitle: item.type !== ItemType.Collection ? getDisplayDate(item) : undefined,
href,
poster: item.poster,
thumbnail: item.thumbnail,
};
};
@ -70,7 +73,7 @@ const query = (
export const BrowsePage: QueryPage<{ slug?: string }> = ({ slug }) => {
const [sortKey, setSort] = useState(SortBy.Name);
const [sortOrd, setSortOrd] = useState(SortOrd.Asc);
const [layout, setLayout] = useState(Layout.Grid);
const [layout, setLayout] = useState(Layout.List);
const LayoutComponent = layout === Layout.Grid ? ItemGrid : ItemList;

View File

@ -18,98 +18,101 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
const ItemList = ({
import { Link, P, Skeleton, Animated, ts, ImageBackground, Heading } from "@kyoo/primitives";
import { useState } from "react";
import { View } from "react-native";
import { percent, px, rem, useYoshiki } from "yoshiki/native";
import { Layout, WithLoading } from "../fetch";
export const ItemList = ({
href,
name,
subtitle,
thumbnail,
poster,
loading,
}: {
href?: string;
name?: string;
subtitle?: string | null;
isLoading,
}: WithLoading<{
href: string;
name: string;
subtitle?: string;
poster?: string | null;
thumbnail?: string | null;
loading?: boolean;
}) => {
}>) => {
const { css } = useYoshiki();
const [isHovered, setHovered] = useState(0);
return (
<Link
<ImageBackground
src={thumbnail}
alt={name}
as={Link}
href={href ?? ""}
color="inherit"
sx={{
display: "flex",
textAlign: "center",
onFocus={() => setHovered((i) => i + 1)}
onBlur={() => setHovered((i) => i - 1)}
onPressIn={() => setHovered((i) => i + 1)}
onPressOut={() => setHovered((i) => i - 1)}
containerStyle={{
borderRadius: px(6),
}}
imageStyle={{
borderRadius: px(6),
}}
{...css({
alignItems: "center",
justifyContent: "space-evenly",
width: "100%",
height: "300px",
flexDirection: "row",
m: 1,
position: "relative",
color: "white",
"&:hover .poster": {
transform: "scale(1.3)",
},
}}
height: ItemList.layout.size,
borderRadius: px(6),
m: ts(1),
})}
>
<Image
src={thumbnail}
alt={name}
width="100%"
height="100%"
radius={px(5)}
css={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
"&::after": {
content: '""',
position: "absolute",
top: 0,
bottom: 0,
right: 0,
left: 0,
/* background: "rgba(0, 0, 0, 0.4)", */
background: "linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 0.6) 100%)",
},
}}
/>
<Box
sx={{
display: "flex",
<View
{...css({
flexDirection: "column",
width: { xs: "50%", lg: "30%" },
}}
})}
>
<Typography
variant="button"
sx={{
fontSize: "2rem",
letterSpacing: "0.002rem",
fontWeight: 900,
}}
>
{name ?? <Skeleton />}
</Typography>
{(loading || subtitle) && (
<Typography variant="caption" sx={{ fontSize: "1rem" }}>
{subtitle ?? <Skeleton />}
</Typography>
<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>
)}
</Skeleton>
{(isLoading || subtitle) && (
<Skeleton {...css({ width: rem(5), alignSelf: "center" })}>
{isLoading || (
<P
{...css({
textAlign: "center",
})}
>
{subtitle}
</P>
)}
</Skeleton>
)}
</Box>
<Poster
</View>
<Animated.Poster
src={poster}
alt=""
height="80%"
css={{
transition: "transform .2s",
}}
isLoading={isLoading}
forward={{ layout: { height: percent(80) } }}
// TODO: this does not work on the web...
animate={{ scale: isHovered ? 1.3 : 1 }}
transition={{ type: "spring" }}
/>
</Link>
</ImageBackground>
);
};
ItemList.layout = { numColumns: 1, size: 300 } satisfies Layout;

View File

@ -62,9 +62,11 @@ export const InfiniteFetch = <Data,>({
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
overflow: "unset !important",
},
numColumns === 1 && {
flexDirection: "column",
alignItems: "stretch",
},
numColumns !== 1 && {
flexWrap: "wrap",

View File

@ -58,6 +58,7 @@ export const Navbar = (props: Stylable) => {
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
zIndex: 1,
},
props,
)}

View File

@ -9682,7 +9682,7 @@ __metadata:
react-native-screens: ~3.18.0
react-native-svg: 13.4.0
typescript: ^4.6.3
yoshiki: 0.2.7
yoshiki: 0.2.9
languageName: unknown
linkType: soft
@ -13319,7 +13319,7 @@ __metadata:
superjson: ^1.11.0
typescript: ^4.9.3
webpack: ^5.75.0
yoshiki: 0.2.7
yoshiki: 0.2.9
zod: ^3.19.1
languageName: unknown
linkType: soft
@ -13644,9 +13644,9 @@ __metadata:
languageName: node
linkType: hard
"yoshiki@npm:0.2.7":
version: 0.2.7
resolution: "yoshiki@npm:0.2.7"
"yoshiki@npm:0.2.9":
version: 0.2.9
resolution: "yoshiki@npm:0.2.9"
dependencies:
"@types/node": 18.x.x
"@types/react": 18.x.x
@ -13661,7 +13661,7 @@ __metadata:
optional: true
react-native-web:
optional: true
checksum: 5589cde181d2825a9c25d0ec51b99873a335869989314c3cee3a4503444344a546ccc4705e1a0f76a4ec4faaa14433ae96fa8e2024d6550498b61b193c0fba5f
checksum: 41ff5ff7e4cd99b2bc7453749a1f17ceb63bc9e1123285bc62a12315cac1fb087b58366db50e08b275e7dd0b1ce862df4c3acae51872f8e3aa8765ee9b61d4bf
languageName: node
linkType: hard