Move primitives

This commit is contained in:
Zoe Roux
2025-02-02 22:00:09 +01:00
parent 4a3d033562
commit c1e3a67a4e
74 changed files with 122 additions and 321 deletions
+1
View File
@@ -1,3 +1,4 @@
export * from "./page";
export * from "./kyoo-error";
export * from "./resources";
export * from "./traits";
+35
View File
@@ -0,0 +1,35 @@
/*
* 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/>.
*/
// Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
import { type AlertButton, type AlertOptions, Alert as RNAlert } from "react-native";
import type { SweetAlertIcon } from "sweetalert2";
export interface ExtendedAlertStatic {
alert: (
title: string,
message?: string,
buttons?: AlertButton[],
options?: AlertOptions & { icon?: SweetAlertIcon },
) => void;
}
export const Alert: ExtendedAlertStatic = RNAlert as ExtendedAlertStatic;
+68
View File
@@ -0,0 +1,68 @@
/*
* 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/>.
*/
// Stolen from https://github.com/necolas/react-native-web/issues/1026#issuecomment-1458279681
import type { AlertButton, AlertOptions } from "react-native";
import Swal, { type SweetAlertIcon } from "sweetalert2";
// biome-ignore lint/complexity/noStaticOnlyClass: Compatibility with rn
export class Alert {
static alert(
title: string,
message?: string,
buttons?: AlertButton[],
options?: AlertOptions & { icon?: SweetAlertIcon },
): void {
const confirmButton = buttons
? buttons.find((button) => button.style === "default")
: undefined;
const denyButton = buttons
? buttons.find((button) => button.style === "destructive")
: undefined;
const cancelButton = buttons ? buttons.find((button) => button.style === "cancel") : undefined;
Swal.fire({
title: title,
text: message,
showConfirmButton: !!confirmButton,
showDenyButton: !!denyButton,
showCancelButton: !!cancelButton,
confirmButtonText: confirmButton?.text,
denyButtonText: denyButton?.text,
cancelButtonText: cancelButton?.text,
icon: options?.icon,
}).then((result) => {
if (result.isConfirmed) {
if (confirmButton?.onPress !== undefined) {
confirmButton.onPress();
}
} else if (result.isDenied) {
if (denyButton?.onPress !== undefined) {
denyButton.onPress();
}
} else if (result.isDismissed) {
if (cancelButton?.onPress !== undefined) {
cancelButton.onPress();
}
}
});
}
}
+127
View File
@@ -0,0 +1,127 @@
/*
* 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 AccountCircle from "@material-symbols/svg-400/rounded/account_circle-fill.svg";
import { type ComponentType, type RefAttributes, forwardRef } from "react";
import { Image, type ImageProps, View, type ViewStyle } from "react-native";
import { type Stylable, px, useYoshiki } from "yoshiki/native";
import { Icon } from "./icons";
import { Skeleton } from "./skeleton";
import { P } from "./text";
const stringToColor = (string: string) => {
let hash = 0;
for (let i = 0; i < string.length; i += 1) {
hash = string.charCodeAt(i) + ((hash << 5) - hash);
}
let color = "#";
for (let i = 0; i < 3; i += 1) {
const value = (hash >> (i * 8)) & 0xff;
color += `00${value.toString(16)}`.slice(-2);
}
return color;
};
const AvatarC = forwardRef<
View,
{
src?: string;
alt?: string;
size?: number;
placeholder?: string;
color?: string;
fill?: boolean;
as?: ComponentType<{ style?: ViewStyle } & RefAttributes<View>>;
} & Stylable
>(function AvatarI(
{ src, alt, size = px(24), color, placeholder, fill = false, as, ...props },
ref,
) {
const { css, theme } = useYoshiki();
const col = color ?? theme.overlay0;
// TODO: Support dark themes when fill === true
const Container = as ?? View;
return (
<Container
ref={ref}
{...css(
[
{
borderRadius: 999999,
overflow: "hidden",
height: size,
width: size,
},
fill && {
bg: col,
},
placeholder && {
bg: stringToColor(placeholder),
},
],
props,
)}
>
{placeholder ? (
<P
{...css({
marginVertical: 0,
lineHeight: size,
textAlign: "center",
})}
>
{placeholder[0]}
</P>
) : (
<Icon icon={AccountCircle} size={size} color={fill ? col : theme.colors.white} />
)}
<Image
resizeMode="cover"
source={{ uri: src, width: size, height: size }}
alt={alt}
width={size}
height={size}
{...(css({ position: "absolute" }) as ImageProps)}
/>
</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 });
+101
View File
@@ -0,0 +1,101 @@
/*
* 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 { type ComponentType, type ForwardedRef, type ReactElement, forwardRef } from "react";
import { type Falsy, type PressableProps, View } from "react-native";
import { type Theme, useYoshiki } from "yoshiki/native";
import { PressableFeedback } from "./links";
import { P } from "./text";
import { ts } from "./utils";
export const Button = forwardRef(function Button<AsProps = PressableProps>(
{
children,
text,
icon,
licon,
disabled,
as,
...props
}: {
children?: ReactElement | ReactElement[] | Falsy;
text?: string;
licon?: ReactElement | Falsy;
icon?: ReactElement | Falsy;
disabled?: boolean;
as?: ComponentType<AsProps>;
} & AsProps,
ref: ForwardedRef<unknown>,
) {
const { css } = useYoshiki("button");
const Container = as ?? PressableFeedback;
return (
<Container
ref={ref as any}
disabled={disabled}
{...(css(
[
{
flexGrow: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
p: ts(0.5),
borderRadius: ts(5),
borderColor: (theme: Theme) => theme.accent,
borderWidth: ts(0.5),
fover: {
self: { bg: (theme: Theme) => theme.accent },
text: { color: (theme: Theme) => theme.colors.white },
},
},
disabled && {
child: {
self: {
borderColor: (theme) => theme.overlay1,
},
text: {
color: (theme) => theme.overlay1,
},
},
},
],
props as any,
) as AsProps)}
>
{(licon || text || icon) != null && (
<View
{...css({
paddingX: ts(3),
flexDirection: "row",
alignItems: "center",
})}
>
{licon}
{text && <P {...css({ textAlign: "center" }, "text")}>{text}</P>}
{icon}
</View>
)}
{children}
</Container>
);
});
+142
View File
@@ -0,0 +1,142 @@
/*
* 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 { type TextProps, View } from "react-native";
import { type Theme, px, rem, useYoshiki } from "yoshiki/native";
import { Link } from "./links";
import { Skeleton } from "./skeleton";
import { P } from "./text";
import { capitalize, ts } from "./utils";
export const Chip = ({
color,
size = "medium",
outline = false,
label,
href,
replace,
target,
textProps,
...props
}: {
color?: string;
size?: "small" | "medium" | "large";
outline?: boolean;
label?: string;
href?: string;
replace?: boolean;
target?: string;
textProps?: TextProps;
}) => {
const { css } = useYoshiki("chip");
textProps ??= {};
const sizeMult = size === "medium" ? 1 : size === "small" ? 0.5 : 1.5;
return (
<Link
href={href}
replace={replace}
target={target}
{...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),
fover: {
self: {
bg: (theme: Theme) => theme.accent,
},
text: {
color: (theme: Theme) => theme.alternate.contrast,
},
},
},
!outline && {
bg: color ?? ((theme: Theme) => theme.accent),
},
],
props,
)}
>
<P
{...css(
[
"text",
{
marginVertical: 0,
fontSize: rem(0.8),
color: (theme: Theme) => (outline ? theme.contrast : theme.alternate.contrast),
},
],
textProps,
)}
>
{label ? capitalize(label) : <Skeleton {...css({ width: rem(3) })} />}
</P>
</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>
);
};
+1
View File
@@ -0,0 +1 @@
export const imageBorderRadius = 10;
+50
View File
@@ -0,0 +1,50 @@
/*
* 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 type { ComponentType } from "react";
import { View, type ViewProps } from "react-native";
import { percent, px, useYoshiki } from "yoshiki/native";
export const Container = <AsProps = ViewProps>({
as,
...props
}: { as?: ComponentType<AsProps> } & AsProps) => {
const { css } = useYoshiki();
const As = as ?? View;
return (
<As
{...(css(
{
display: "flex",
paddingHorizontal: px(15),
alignSelf: "center",
width: {
xs: percent(100),
sm: px(540),
md: px(880),
lg: px(1170),
},
},
props,
) as any)}
/>
);
};
+57
View File
@@ -0,0 +1,57 @@
/*
* 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 { HR as EHR } from "@expo/html-elements";
import { type Stylable, px, useYoshiki } from "yoshiki/native";
import { ts } from "./utils";
export const HR = ({
orientation = "horizontal",
...props
}: { orientation?: "vertical" | "horizontal" } & Stylable) => {
const { css } = useYoshiki();
return (
<EHR
{...css(
[
{
opacity: 0.7,
bg: (theme) => theme.overlay0,
borderWidth: 0,
},
orientation === "vertical" && {
width: px(1),
height: "auto",
marginY: ts(1),
marginX: ts(2),
},
orientation === "horizontal" && {
height: px(1),
width: "auto",
marginX: ts(1),
marginY: ts(2),
},
],
props,
)}
/>
);
};
+140
View File
@@ -0,0 +1,140 @@
/*
* 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 type React from "react";
import { type ComponentProps, type ComponentType, type ForwardedRef, forwardRef } from "react";
import { Platform, type PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg";
import type { YoshikiStyle } from "yoshiki";
import { type Stylable, type Theme, px, useYoshiki } from "yoshiki/native";
import { PressableFeedback } from "./links";
import { P } from "./text";
import { type Breakpoint, focusReset, ts } from "./utils";
declare module "react" {
function forwardRef<T, P = {}>(
render: (props: P, ref: React.ForwardedRef<T>) => React.ReactElement | null,
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
}
export type Icon = ComponentType<SvgProps>;
type IconProps = {
icon: Icon;
color?: Breakpoint<string>;
size?: YoshikiStyle<number | string>;
};
export const Icon = ({ icon: Icon, color, size = 24, ...props }: IconProps) => {
const { css, theme } = useYoshiki();
const computed = css(
{ width: size, height: size, fill: color ?? theme.contrast, flexShrink: 0 } as any,
props,
) as any;
return (
<Icon
{...Platform.select<SvgProps>({
web: computed,
default: {
height: computed.style[0]?.height,
width: computed.style[0]?.width,
fill: computed.style[0]?.fill,
...computed,
},
})}
/>
);
};
export const IconButton = forwardRef(function IconButton<AsProps = PressableProps>(
{
icon,
size,
color,
as,
...asProps
}: IconProps & {
as?: ComponentType<AsProps>;
} & AsProps,
ref: ForwardedRef<unknown>,
) {
const { css, theme } = useYoshiki();
const Container = as ?? PressableFeedback;
return (
<Container
ref={ref as any}
focusRipple
{...(css(
{
alignSelf: "center",
p: ts(1),
m: px(2),
overflow: "hidden",
borderRadius: 9999,
fover: {
self: {
...focusReset,
bg: (theme: Theme) => theme.overlay0,
},
},
},
asProps,
) as AsProps)}
>
<Icon
icon={icon}
size={size}
color={"disabled" in asProps && asProps.disabled ? theme.overlay1 : color}
/>
</Container>
);
});
export const IconFab = <AsProps = PressableProps>(
props: ComponentProps<typeof IconButton<AsProps>>,
) => {
const { css, theme } = useYoshiki();
return (
<IconButton
color={theme.colors.black}
{...(css(
{
bg: (theme) => theme.accent,
fover: {
self: {
transform: "scale(1.3)" as any,
bg: (theme: Theme) => theme.accent,
},
},
},
props,
) as any)}
/>
);
};
export const DottedSeparator = (props: Stylable) => {
const { css } = useYoshiki();
return <P {...css({ mX: ts(1) }, props)}>{String.fromCharCode(0x2022)}</P>;
};
+44
View File
@@ -0,0 +1,44 @@
/*
* 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 type { ReactElement } from "react";
import type { ImageStyle } from "react-native";
import type { YoshikiStyle } from "yoshiki/src/type";
import type { KyooImage } from "~/models";
export type YoshikiEnhanced<Style> = Style extends any
? {
[key in keyof Style]: YoshikiStyle<Style[key]>;
}
: never;
export type Props = {
src?: KyooImage | null;
quality: "low" | "medium" | "high";
alt?: string;
Err?: ReactElement | null;
forcedLoading?: boolean;
};
export type ImageLayout = YoshikiEnhanced<
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
>;
+43
View File
@@ -0,0 +1,43 @@
/*
* 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 type { ReactElement } from "react";
import { View } from "react-native";
import { Blurhash } from "react-native-blurhash";
import { type Stylable, useYoshiki } from "yoshiki/native";
export const BlurhashContainer = ({
blurhash,
children,
...props
}: { blurhash: string; children?: ReactElement | ReactElement[] } & Stylable) => {
const { css } = useYoshiki();
return (
<View {...props}>
<Blurhash
blurhash={blurhash}
resizeMode="cover"
{...css({ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 })}
/>
{children}
</View>
);
};
+322
View File
@@ -0,0 +1,322 @@
/*
* 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 { decode } from "blurhash";
import {
type HTMLAttributes,
type ReactElement,
forwardRef,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useYoshiki } from "yoshiki";
import { nativeStyleToCss } from "yoshiki/native";
// The blurhashToUrl has been stolen from https://gist.github.com/mattiaz9/53cb67040fa135cb395b1d015a200aff
export function blurHashToDataURL(hash: string | undefined): string | undefined {
if (!hash) return undefined;
const pixels = decode(hash, 32, 32);
const dataURL = parsePixels(pixels, 32, 32);
return dataURL;
}
// thanks to https://github.com/wheany/js-png-encoder
function parsePixels(pixels: Uint8ClampedArray, width: number, height: number) {
const pixelsString = Array.from(pixels)
.map((byte) => String.fromCharCode(byte))
.join("");
const pngString = generatePng(width, height, pixelsString);
const dataURL =
typeof Buffer !== "undefined"
? Buffer.from(getPngArray(pngString)).toString("base64")
: btoa(pngString);
return `data:image/png;base64,${dataURL}`;
}
function getPngArray(pngString: string) {
const pngArray = new Uint8Array(pngString.length);
for (let i = 0; i < pngString.length; i++) {
pngArray[i] = pngString.charCodeAt(i);
}
return pngArray;
}
function generatePng(width: number, height: number, rgbaString: string) {
const DEFLATE_METHOD = String.fromCharCode(0x78, 0x01);
const CRC_TABLE: number[] = [];
const SIGNATURE = String.fromCharCode(137, 80, 78, 71, 13, 10, 26, 10);
const NO_FILTER = String.fromCharCode(0);
// biome-ignore lint: not gonna fix stackowerflow code that works
let n, c, k;
// make crc table
for (n = 0; n < 256; n++) {
c = n;
for (k = 0; k < 8; k++) {
if (c & 1) {
c = 0xedb88320 ^ (c >>> 1);
} else {
c = c >>> 1;
}
}
CRC_TABLE[n] = c;
}
// Functions
function inflateStore(data: string) {
const MAX_STORE_LENGTH = 65535;
let storeBuffer = "";
// biome-ignore lint: not gonna fix stackowerflow code that works
let remaining;
// biome-ignore lint: not gonna fix stackowerflow code that works
let blockType;
for (let i = 0; i < data.length; i += MAX_STORE_LENGTH) {
remaining = data.length - i;
blockType = "";
if (remaining <= MAX_STORE_LENGTH) {
blockType = String.fromCharCode(0x01);
} else {
remaining = MAX_STORE_LENGTH;
blockType = String.fromCharCode(0x00);
}
// little-endian
storeBuffer += blockType + String.fromCharCode(remaining & 0xff, (remaining & 0xff00) >>> 8);
storeBuffer += String.fromCharCode(~remaining & 0xff, (~remaining & 0xff00) >>> 8);
storeBuffer += data.substring(i, i + remaining);
}
return storeBuffer;
}
function adler32(data: string) {
const MOD_ADLER = 65521;
let a = 1;
let b = 0;
for (let i = 0; i < data.length; i++) {
a = (a + data.charCodeAt(i)) % MOD_ADLER;
b = (b + a) % MOD_ADLER;
}
return (b << 16) | a;
}
function updateCrc(crc: number, buf: string) {
let c = crc;
let b: number;
for (let n = 0; n < buf.length; n++) {
b = buf.charCodeAt(n);
c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
}
return c;
}
function crc(buf: string) {
return updateCrc(0xffffffff, buf) ^ 0xffffffff;
}
function dwordAsString(dword: number) {
return String.fromCharCode(
(dword & 0xff000000) >>> 24,
(dword & 0x00ff0000) >>> 16,
(dword & 0x0000ff00) >>> 8,
dword & 0x000000ff,
);
}
function createChunk(length: number, type: string, data: string) {
const CRC = crc(type + data);
return dwordAsString(length) + type + data + dwordAsString(CRC);
}
function createIHDR(width: number, height: number) {
const IHDRdata =
dwordAsString(width) +
dwordAsString(height) +
// bit depth
String.fromCharCode(8) +
// color type: 6=truecolor with alpha
String.fromCharCode(6) +
// compression method: 0=deflate, only allowed value
String.fromCharCode(0) +
// filtering: 0=adaptive, only allowed value
String.fromCharCode(0) +
// interlacing: 0=none
String.fromCharCode(0);
return createChunk(13, "IHDR", IHDRdata);
}
// PNG creations
const IEND = createChunk(0, "IEND", "");
const IHDR = createIHDR(width, height);
let scanlines = "";
let scanline: string;
for (let y = 0; y < rgbaString.length; y += width * 4) {
scanline = NO_FILTER;
if (Array.isArray(rgbaString)) {
for (let x = 0; x < width * 4; x++) {
scanline += String.fromCharCode(rgbaString[y + x] & 0xff);
}
} else {
scanline += rgbaString.substr(y, width * 4);
}
scanlines += scanline;
}
const compressedScanlines =
DEFLATE_METHOD + inflateStore(scanlines) + dwordAsString(adler32(scanlines));
const IDAT = createChunk(compressedScanlines.length, "IDAT", compressedScanlines);
const pngString = SIGNATURE + IHDR + IDAT + IEND;
return pngString;
}
const BlurhashCanvas = forwardRef<
HTMLCanvasElement,
{
blurhash: string;
} & HTMLAttributes<HTMLCanvasElement>
>(function BlurhashCanvas({ blurhash, ...props }, forwardedRef) {
const ref = useRef<HTMLCanvasElement>(null);
const { css } = useYoshiki();
useImperativeHandle(forwardedRef, () => ref.current!, []);
useLayoutEffect(() => {
if (!ref.current) return;
const pixels = decode(blurhash, 32, 32);
const ctx = ref.current.getContext("2d");
if (!ctx) return;
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}, [blurhash]);
return (
<canvas
ref={ref}
width={32}
height={32}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
},
props,
)}
/>
);
});
const BlurhashDiv = forwardRef<
HTMLDivElement,
{ blurhash: string } & HTMLAttributes<HTMLDivElement>
>(function BlurhashDiv({ blurhash, ...props }, ref) {
const { css } = useYoshiki();
return (
<div
ref={ref}
style={{
// Use a blurhash here to nicely fade the NextImage when it is loaded completly
// (this prevents loading the image line by line which is ugly and buggy on firefox)
backgroundImage: `url(${blurHashToDataURL(blurhash)})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "50% 50%",
}}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
width: "100%",
height: "100%",
},
props,
)}
/>
);
});
export const useRenderType = () => {
const [renderType, setRenderType] = useState<"ssr" | "hydratation" | "client">(
typeof window === "undefined" ? "ssr" : "hydratation",
);
useLayoutEffect(() => {
setRenderType("client");
}, []);
return renderType;
};
export const BlurhashContainer = ({
blurhash,
children,
...props
}: {
blurhash: string;
children?: ReactElement | ReactElement[];
}) => {
const { css } = useYoshiki();
const renderType = useRenderType();
return (
<div
{...css(
{
// To reproduce view's behavior
boxSizing: "border-box",
overflow: "hidden",
position: "relative",
},
nativeStyleToCss(props),
)}
>
{renderType === "ssr" && <BlurhashDiv blurhash={blurhash} />}
{renderType === "client" && <BlurhashCanvas blurhash={blurhash} />}
{renderType === "hydratation" && (
<div dangerouslySetInnerHTML={{ __html: "" }} suppressHydrationWarning />
)}
{children}
</div>
);
};
+102
View File
@@ -0,0 +1,102 @@
/*
* 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 { getCurrentToken } from "@kyoo/models";
import { type ReactElement, useState } from "react";
import { type FlexStyle, type ImageStyle, View, type ViewStyle } from "react-native";
import { Blurhash } from "react-native-blurhash";
import FastImage from "react-native-fast-image";
import { percent, useYoshiki } from "yoshiki/native";
import { Skeleton } from "../skeleton";
import type { ImageLayout, Props } from "./base-image";
export const Image = ({
src,
quality,
alt,
forcedLoading = false,
layout,
Err,
...props
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki();
const [state, setState] = useState<"loading" | "errored" | "finished">(
src ? "loading" : "errored",
);
// This could be done with a key but this makes the API easier to use.
// This unsures that the state is resetted when the source change (useful for recycler lists.)
const [oldSource, setOldSource] = useState(src);
if (oldSource !== src) {
setState("loading");
setOldSource(src);
}
const border = { borderRadius: 6, overflow: "hidden" } satisfies ViewStyle;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
if (!src || state === "errored") {
return Err !== undefined ? (
Err
) : (
<View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />
);
}
quality ??= "high";
const token = getCurrentToken();
return (
<View {...css([layout, border], props)}>
{state !== "finished" && (
<Blurhash
blurhash={src.blurhash}
resizeMode="cover"
{...css({ width: percent(100), height: percent(100) })}
/>
)}
<FastImage
source={{
uri: src[quality],
headers: token
? {
Authorization: token,
}
: {},
priority: FastImage.priority[quality === "medium" ? "normal" : quality],
}}
accessibilityLabel={alt}
onLoad={() => setState("finished")}
onError={() => setState("errored")}
resizeMode={FastImage.resizeMode.cover}
{...(css({
width: percent(100),
height: percent(100),
}) as { style: FlexStyle })}
/>
</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)} />;
};
+89
View File
@@ -0,0 +1,89 @@
/*
* 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 NextImage from "next/image";
import { type ReactElement, useState } from "react";
import { type ImageStyle, View, type ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { imageBorderRadius } from "../constants";
import { Skeleton } from "../skeleton";
import type { ImageLayout, Props } from "./base-image";
import { BlurhashContainer, useRenderType } from "./blurhash.web";
export const Image = ({
src,
quality,
alt,
forcedLoading = false,
layout,
Err,
...props
}: Props & { style?: ImageStyle } & { layout: ImageLayout }) => {
const { css } = useYoshiki();
const [state, setState] = useState<"loading" | "errored" | "finished">(
typeof window === "undefined" ? "finished" : "loading",
);
const border = { borderRadius: imageBorderRadius } satisfies ViewStyle;
if (forcedLoading) return <Skeleton variant="custom" {...css([layout, border], props)} />;
if (!src || state === "errored") {
return Err !== undefined ? (
Err
) : (
<View {...css([{ bg: (theme) => theme.overlay0 }, layout, border], props)} />
);
}
return (
<BlurhashContainer blurhash={src.blurhash} {...css([layout, border], props)}>
<img
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
color: "transparent",
objectFit: "cover",
opacity: state === "loading" ? 0 : 1,
transition: "opacity .2s ease-out",
}}
// It's intended to keep `loading` before `src` because React updates
// props in order which causes Safari/Firefox to not lazy load properly.
// See https://github.com/facebook/react/issues/25883
loading={quality === "high" ? "eager" : "lazy"}
decoding="async"
fetchpriority={quality === "high" ? "high" : undefined}
src={src[quality ?? "high"]}
alt={alt!}
onLoad={() => setState("finished")}
onError={() => setState("errored")}
suppressHydrationWarning
/>
</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)} />;
};
+170
View File
@@ -0,0 +1,170 @@
/*
* 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 { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
import type { ComponentProps, ComponentType, ReactElement, ReactNode } from "react";
import { type ImageStyle, View, type ViewProps, type ViewStyle } from "react-native";
import { percent } from "yoshiki/native";
import { useYoshiki } from "yoshiki/native";
import { imageBorderRadius } from "../constants";
import { ContrastArea } from "../themes";
import type { ImageLayout, Props, YoshikiEnhanced } from "./base-image";
import { Image } from "./image";
export { Sprite } from "./sprite";
export { BlurhashContainer } from "./blurhash";
export { type Props as ImageProps, Image };
export const Poster = ({
alt,
layout,
...props
}: Props & { style?: ImageStyle } & {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <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 = ({
alt,
layout,
...props
}: Omit<ComponentProps<typeof ImageBackground>, "layout"> & { style?: ImageStyle } & {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => {
const { css } = useYoshiki();
return (
<ImageBackground
alt={alt!}
layout={{ aspectRatio: 2 / 3, ...layout }}
{...css({ borderRadius: imageBorderRadius }, props)}
/>
);
};
type ImageBackgroundProps = {
children?: ReactNode;
containerStyle?: YoshikiEnhanced<ViewStyle>;
imageStyle?: YoshikiEnhanced<ImageStyle>;
layout?: ImageLayout;
contrast?: "light" | "dark" | "user";
};
export const ImageBackground = <AsProps = ViewProps>({
src,
alt,
quality,
as,
children,
containerStyle,
imageStyle,
layout,
contrast = "dark",
imageSibling,
...asProps
}: {
as?: ComponentType<AsProps>;
imageSibling?: ReactElement;
} & AsProps &
ImageBackgroundProps &
Props) => {
const Container = as ?? View;
return (
<ContrastArea contrastText mode={contrast}>
{({ css }) => (
<Container {...(css([layout, { overflow: "hidden" }], asProps) as AsProps)}>
<View
{...css([
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
bg: (theme) => theme.background,
},
containerStyle,
])}
>
{src && (
<Image
src={src}
quality={quality}
alt={alt!}
layout={{ width: percent(100), height: percent(100) }}
{...(css([{ borderWidth: 0, borderRadius: 0 }, imageStyle]) as {
style: ImageStyle;
})}
/>
)}
{imageSibling}
</View>
{children}
</Container>
)}
</ContrastArea>
);
};
export const GradientImageBackground = <AsProps = ViewProps>({
contrast = "dark",
gradient,
...props
}: {
as?: ComponentType<AsProps>;
gradient?: Partial<LinearGradientProps>;
} & AsProps &
ImageBackgroundProps &
Props) => {
const { css, theme } = useYoshiki();
return (
<ImageBackground
contrast={contrast}
imageSibling={
<LinearGradient
start={{ x: 0, y: 0.25 }}
end={{ x: 0, y: 1 }}
colors={["transparent", theme[contrast].darkOverlay]}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
typeof gradient === "object" ? gradient : undefined,
)}
/>
}
{...(props as any)}
/>
);
};
+57
View File
@@ -0,0 +1,57 @@
/*
* 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 { Image, View } from "react-native";
export const Sprite = ({
src,
alt,
width,
height,
x,
y,
rows,
columns,
style,
...props
}: {
src: string;
alt: string;
width: number;
height: number;
x: number;
y: number;
rows: number;
columns: number;
style?: object;
}) => {
return (
<View style={{ width, height, overflow: "hidden", flexGrow: 0, flexShrink: 0 }}>
<Image
source={{ uri: src }}
alt={alt}
width={width * columns}
height={height * rows}
style={{ transform: [{ translateX: -x }, { translateY: -y }] }}
{...props}
/>
</View>
);
};
+56
View File
@@ -0,0 +1,56 @@
/*
* 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 NextImage from "next/image";
export const Sprite = ({
src,
alt,
style,
x,
y,
...props
}: {
src: string;
alt: string;
style?: object;
width: number;
height: number;
x: number;
y: number;
}) => {
return (
<NextImage
src={src}
priority={false}
alt={alt!}
// Don't use next's server to reprocess images, they are already optimized by kyoo.
unoptimized={true}
style={{
objectFit: "none",
objectPosition: `${-x}px ${-y}px`,
flexGrow: 0,
flexShrink: 0,
...style,
}}
{...props}
/>
);
};
+44
View File
@@ -0,0 +1,44 @@
/*
* 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/>.
*/
export { Header, Main, Nav, Footer, UL } from "@expo/html-elements";
export * from "./text";
export * from "./themes";
export * from "./icons";
export * from "./links";
export * from "./avatar";
export * from "./image";
export * from "./skeleton";
export * from "./tooltip";
export * from "./container";
export * from "./divider";
export * from "./progress";
export * from "./slider";
export * from "./snackbar";
export * from "./alert";
export * from "./menu";
export * from "./popup";
export * from "./select";
export * from "./input";
export * from "./button";
export * from "./chip";
export * from "./utils";
export * from "./constants";
+82
View File
@@ -0,0 +1,82 @@
/*
* 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 { type ReactNode, forwardRef, useState } from "react";
import { TextInput, type TextInputProps, View, type ViewStyle } from "react-native";
import { type Theme, px, useYoshiki } from "yoshiki/native";
import type { YoshikiEnhanced } from "./image/base-image";
import { focusReset, ts } from "./utils";
export const Input = forwardRef<
TextInput,
{
variant?: "small" | "big";
right?: ReactNode;
containerStyle?: YoshikiEnhanced<ViewStyle>;
} & TextInputProps
>(function Input(
{ placeholderTextColor, variant = "small", right, containerStyle, ...props },
ref,
) {
const [focused, setFocused] = useState(false);
const { css, theme } = useYoshiki();
return (
<View
{...css([
{
borderColor: (theme) => theme.accent,
borderRadius: ts(1),
borderWidth: px(1),
borderStyle: "solid",
padding: ts(0.5),
flexDirection: "row",
alignContent: "center",
alignItems: "center",
},
variant === "big" && {
borderRadius: ts(4),
p: ts(1),
},
focused && {
borderWidth: px(2),
},
containerStyle,
])}
>
<TextInput
ref={ref}
placeholderTextColor={placeholderTextColor ?? theme.paragraph}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
{...css(
{
flexGrow: 1,
color: (theme: Theme) => theme.paragraph,
borderWidth: 0,
...focusReset,
},
props,
)}
/>
{right}
</View>
);
});
+133
View File
@@ -0,0 +1,133 @@
/*
* 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 type { UrlObject } from "node:url";
import { type ReactNode, forwardRef } from "react";
import {
Linking,
Platform,
Pressable,
type PressableProps,
type TextProps,
type View,
} from "react-native";
import { TextLink, useLink } from "solito/link";
import { parseNextPath } from "solito/router";
import { useTheme, useYoshiki } from "yoshiki/native";
import { alpha } from "./themes";
export const A = ({
href,
replace,
children,
target,
...props
}: TextProps & {
href?: string | UrlObject | null;
target?: string;
replace?: boolean;
children: ReactNode;
}) => {
const { css, theme } = useYoshiki();
return (
<TextLink
href={href ?? ""}
target={target}
replace={replace as any}
experimental={
replace
? {
nativeBehavior: "stack-replace",
isNestedNavigator: true,
}
: undefined
}
textProps={css(
[
{
fontFamily: theme.font.normal,
color: theme.link,
},
{
userSelect: "text",
} as any,
],
{
hrefAttrs: { target },
...props,
},
)}
>
{children}
</TextLink>
);
};
export const PressableFeedback = forwardRef<View, PressableProps>(function Feedback(
{ children, ...props },
ref,
) {
const theme = useTheme();
return (
<Pressable
ref={ref}
// TODO: Enable ripple on tv. Waiting for https://github.com/react-native-tvos/react-native-tvos/issues/440
{...(Platform.isTV
? {}
: { android_ripple: { foreground: true, color: alpha(theme.contrast, 0.5) as any } })}
{...props}
>
{children}
</Pressable>
);
});
export const Link = ({
href: link,
replace,
target,
children,
...props
}: { href?: string | UrlObject | null; target?: string; replace?: boolean } & PressableProps) => {
const href = link && typeof link === "object" ? parseNextPath(link) : link;
const linkProps = useLink({
href: href ?? "#",
replace,
experimental: { nativeBehavior: "stack-replace", isNestedNavigator: true },
});
// @ts-ignore Missing hrefAttrs type definition.
linkProps.hrefAttrs = { ...linkProps.hrefAttrs, target };
return (
<PressableFeedback
{...linkProps}
{...props}
onPress={(e?: any) => {
props?.onPress?.(e);
if (e?.defaultPrevented) return;
if (Platform.OS !== "web" && href?.includes("://")) Linking.openURL(href);
else linkProps.onPress(e);
}}
>
{children}
</PressableFeedback>
);
};
+230
View File
@@ -0,0 +1,230 @@
/*
* 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 { Portal } from "@gorhom/portal";
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import { ScrollView } from "moti";
import {
type ComponentType,
type ReactElement,
type ReactNode,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { SvgProps } from "react-native-svg";
import { useRouter } from "solito/router";
import { percent, px, sm, useYoshiki, vh, xl } from "yoshiki/native";
import { Icon, IconButton } from "./icons";
import { PressableFeedback } from "./links";
import { P } from "./text";
import { ContrastArea, SwitchVariant } from "./themes";
import { ts } from "./utils";
const MenuContext = createContext<((open: boolean) => void) | undefined>(undefined);
type Optional<T, K extends keyof any> = Omit<T, K> & Partial<T>;
const Menu = <AsProps,>({
Trigger,
onMenuOpen,
onMenuClose,
children,
isOpen: outerOpen,
setOpen: outerSetOpen,
...props
}: {
Trigger: ComponentType<AsProps>;
children?: ReactNode | ReactNode[] | null;
onMenuOpen?: () => void;
onMenuClose?: () => void;
isOpen?: boolean;
setOpen?: (v: boolean) => void;
} & Optional<AsProps, "onPress">) => {
const insets = useSafeAreaInsets();
const alreadyRendered = useRef(false);
const [isOpen, setOpen] =
outerOpen !== undefined && outerSetOpen ? [outerOpen, outerSetOpen] : useState(false);
// does the same as a useMemo but for props.
const memoRef = useRef({ onMenuOpen, onMenuClose });
memoRef.current = { onMenuOpen, onMenuClose };
useEffect(() => {
if (isOpen) memoRef.current.onMenuOpen?.();
else if (alreadyRendered.current) memoRef.current.onMenuClose?.();
alreadyRendered.current = true;
}, [isOpen]);
return (
<>
<Trigger
onPress={() => {
setOpen(true);
}}
{...(props as any)}
/>
{isOpen && (
<Portal>
<ContrastArea mode="user">
<SwitchVariant>
{({ css, theme }) => (
<MenuContext.Provider value={setOpen}>
<Pressable
onPress={() => setOpen(false)}
tabIndex={-1}
{...css({ ...StyleSheet.absoluteFillObject, flexGrow: 1, bg: "transparent" })}
/>
<View
{...css([
{
bg: (theme) => theme.background,
position: "absolute",
bottom: 0,
width: percent(100),
maxHeight: vh(80),
alignSelf: "center",
borderTopLeftRadius: px(26),
borderTopRightRadius: { xs: px(26), xl: 0 },
paddingTop: { xs: px(26), xl: 0 },
marginTop: { xs: px(72), xl: 0 },
paddingBottom: insets.bottom,
},
sm({
maxWidth: px(640),
marginHorizontal: px(56),
}),
xl({
top: 0,
right: 0,
marginRight: 0,
borderBottomLeftRadius: px(26),
}),
])}
>
<ScrollView>
<IconButton
icon={Close}
color={theme.colors.black}
onPress={() => setOpen(false)}
{...css({ alignSelf: "flex-end", display: { xs: "none", xl: "flex" } })}
/>
{children}
</ScrollView>
</View>
</MenuContext.Provider>
)}
</SwitchVariant>
</ContrastArea>
</Portal>
)}
</>
);
};
const MenuItem = ({
label,
selected,
left,
onSelect,
href,
icon,
disabled,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
icon?: ComponentType<SvgProps>;
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })) => {
const { css, theme } = useYoshiki();
const setOpen = useContext(MenuContext);
const router = useRouter();
const icn = (icon || selected) && (
<Icon
icon={icon ?? Check}
color={disabled ? theme.overlay0 : theme.paragraph}
size={24}
{...css({ paddingX: ts(1) })}
/>
);
return (
<PressableFeedback
onPress={() => {
setOpen?.call(null, false);
onSelect?.call(null);
if (href) router.push(href);
}}
disabled={disabled}
{...css(
{
paddingHorizontal: ts(2),
width: percent(100),
height: ts(5),
alignItems: "center",
flexDirection: "row",
},
props as any,
)}
>
{left && left}
{!left && icn && icn}
<P
{...css([
{ paddingLeft: ts(2) + +!(icon || selected || left) * px(24), flexGrow: 1 },
disabled && { color: theme.overlay0 },
])}
>
{label}
</P>
{left && icn && icn}
</PressableFeedback>
);
};
Menu.Item = MenuItem;
const Sub = <AsProps,>({
children,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
icon?: ComponentType<SvgProps>;
children?: ReactNode | ReactNode[] | null;
} & AsProps) => {
const setOpen = useContext(MenuContext);
return (
<Menu Trigger={MenuItem} onMenuClose={() => setOpen?.(false)} {...props}>
{children}
</Menu>
);
};
Menu.Sub = Sub;
export { Menu };
+246
View File
@@ -0,0 +1,246 @@
/*
* 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 Dot from "@material-symbols/svg-400/rounded/fiber_manual_record-fill.svg";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import Link from "next/link";
import {
type ComponentProps,
type ComponentType,
type ReactElement,
type ReactNode,
forwardRef,
} from "react";
import type { PressableProps } from "react-native";
import type { SvgProps } from "react-native-svg";
import { useYoshiki as useNativeYoshiki } from "yoshiki/native";
import { useYoshiki } from "yoshiki/web";
import { Icon } from "./icons";
import { P } from "./text";
import { ContrastArea, SwitchVariant } from "./themes";
import { focusReset, ts } from "./utils";
type YoshikiFunc<T> = (props: ReturnType<typeof useYoshiki>) => T;
export const YoshikiProvider = ({ children }: { children: YoshikiFunc<ReactNode> }) => {
const yoshiki = useYoshiki();
return <>{children(yoshiki)}</>;
};
export const InternalTriger = forwardRef<unknown, any>(function _Triger(
{ Component, ComponentProps, ...props },
ref,
) {
return (
<Component ref={ref} {...ComponentProps} {...props} onClickCapture={props.onPointerDown} />
);
});
const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
Trigger,
onMenuOpen,
onMenuClose,
children,
isOpen,
setOpen,
...props
}: {
Trigger: ComponentType<AsProps>;
children: ReactNode | ReactNode[] | null;
onMenuOpen?: () => void;
onMenuClose?: () => void;
isOpen?: boolean;
setOpen?: (v: boolean) => void;
} & Omit<AsProps, "onPress">) => {
return (
<DropdownMenu.Root
modal
open={isOpen}
onOpenChange={(newOpen) => {
if (setOpen) setOpen(newOpen);
if (newOpen) onMenuOpen?.call(null);
else onMenuClose?.call(null);
}}
>
<DropdownMenu.Trigger asChild>
<InternalTriger Component={Trigger} ComponentProps={props} />
</DropdownMenu.Trigger>
<ContrastArea mode="user">
<SwitchVariant>
<YoshikiProvider>
{({ css, theme }) => (
<DropdownMenu.Portal>
<DropdownMenu.Content
onFocusOutside={(e) => e.stopImmediatePropagation()}
{...css({
bg: (theme) => theme.background,
overflow: "auto",
minWidth: "220px",
borderRadius: "8px",
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
zIndex: 2,
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
})}
>
{children}
<DropdownMenu.Arrow fill={theme.background} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</YoshikiProvider>
</SwitchVariant>
</ContrastArea>
</DropdownMenu.Root>
);
};
const Item = forwardRef<
HTMLDivElement,
ComponentProps<typeof DropdownMenu.Item> & { href?: string }
>(function _Item({ children, href, onSelect, ...props }, ref) {
if (href) {
return (
<DropdownMenu.Item ref={ref} onSelect={onSelect} {...props} asChild>
<Link href={href} style={{ textDecoration: "none" }}>
{children}
</Link>
</DropdownMenu.Item>
);
}
return (
<DropdownMenu.Item ref={ref} onSelect={onSelect} {...props}>
{children}
</DropdownMenu.Item>
);
});
const MenuItem = forwardRef<
HTMLDivElement,
{
label: string;
icon?: ComponentType<SvgProps>;
left?: ReactElement;
disabled?: boolean;
selected?: boolean;
} & ({ onSelect: () => void; href?: undefined } | { href: string; onSelect?: undefined })
>(function MenuItem({ label, icon, left, selected, onSelect, href, disabled, ...props }, ref) {
const { css: nCss } = useNativeYoshiki();
const { css, theme } = useYoshiki();
const icn = (icon || selected) && (
<Icon
icon={icon ?? Dot}
color={disabled ? theme.overlay0 : theme.paragraph}
size={icon ? 24 : ts(1)}
{...nCss({ paddingX: ts(1) })}
/>
);
return (
<>
<style jsx global>{`
[data-highlighted] {
background: ${theme.variant.accent};
svg {
fill: ${theme.alternate.contrast};
}
div {
color: ${theme.alternate.contrast};
}
}
`}</style>
<Item
ref={ref}
onSelect={onSelect}
href={href}
disabled={disabled}
{...css(
{
display: "flex",
alignItems: "center",
padding: "8px",
height: "32px",
...focusReset,
},
props as any,
)}
>
{left && left}
{!left && icn && icn}
<P
{...nCss([
{ paddingLeft: 8 * 2 + +!(icon || selected || left) * 24, flexGrow: 1 },
disabled && {
color: theme.overlay0,
},
])}
>
{label}
</P>
{left && icn && icn}
</Item>
</>
);
});
Menu.Item = MenuItem;
const Sub = <AsProps,>({
children,
disabled,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
icon?: ComponentType<SvgProps>;
children: ReactNode | ReactNode[] | null;
} & AsProps) => {
const { css, theme } = useYoshiki();
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger asChild disabled={disabled}>
<MenuItem disabled={disabled} {...props} onSelect={(e?: any) => e.preventDefault()} />
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent
onFocusOutside={(e) => e.stopImmediatePropagation()}
{...css({
bg: (theme) => theme.background,
overflow: "auto",
minWidth: "220px",
borderRadius: "8px",
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
zIndex: 2,
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
})}
>
{children}
<DropdownMenu.Arrow fill={theme.background} />
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
);
};
Menu.Sub = Sub;
export { Menu };
+91
View File
@@ -0,0 +1,91 @@
/*
* 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 { usePortal } from "@gorhom/portal";
import { type ReactNode, useCallback, useEffect, useState } from "react";
import { ScrollView, View } from "react-native";
import { px, vh } from "yoshiki/native";
import { imageBorderRadius } from "./constants";
import { Container } from "./container";
import { ContrastArea, SwitchVariant, type YoshikiFunc } from "./themes";
import { ts } from "./utils";
export const Popup = ({ children, ...props }: { children: ReactNode | YoshikiFunc<ReactNode> }) => {
return (
<ContrastArea mode="user">
<SwitchVariant>
{({ css, theme }) => (
<View
{...css({
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
bg: (theme) => theme.themeOverlay,
justifyContent: "center",
alignItems: "center",
})}
>
<Container
{...css(
{
borderRadius: px(imageBorderRadius),
paddingHorizontal: 0,
bg: (theme) => theme.background,
overflow: "hidden",
},
props,
)}
>
<ScrollView
contentContainerStyle={{
paddingHorizontal: px(15),
paddingVertical: ts(4),
gap: ts(2),
}}
{...css({
maxHeight: vh(95),
flexGrow: 0,
flexShrink: 1,
})}
>
{typeof children === "function" ? children({ css, theme }) : children}
</ScrollView>
</Container>
</View>
)}
</SwitchVariant>
</ContrastArea>
);
};
export const usePopup = () => {
const { addPortal, removePortal } = usePortal();
const [current, setPopup] = useState<ReactNode>();
const close = useCallback(() => setPopup(undefined), []);
useEffect(() => {
addPortal("popup", current);
return () => removePortal("popup");
}, [current, addPortal, removePortal]);
return [setPopup, close] as const;
};
+87
View File
@@ -0,0 +1,87 @@
/*
* 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 { ActivityIndicator, Platform, View } from "react-native";
import { Circle, Svg } from "react-native-svg";
import { type Stylable, px, useYoshiki } from "yoshiki/native";
export const CircularProgress = ({
size = 48,
tickness = 5,
color,
...props
}: { size?: number; tickness?: number; color?: string } & Stylable) => {
const { css, theme } = useYoshiki();
if (Platform.OS !== "web")
return <ActivityIndicator size={size} color={color ?? theme.accent} {...props} />;
return (
<View {...css({ width: size, height: size, overflow: "hidden" }, props)}>
<style jsx global>{`
@keyframes circularProgress-svg {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes circularProgress-circle {
0% {
stroke-dasharray: 1px, 200px;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -15px;
}
100% {
stroke-dasharray: 100px, 200px;
stroke-dashoffset: -125px;
}
}
`}</style>
<Svg
viewBox={`${size / 2} ${size / 2} ${size} ${size}`}
{...css(
// @ts-ignore Web only
Platform.OS === "web" && { animation: "circularProgress-svg 1.4s ease-in-out infinite" },
)}
>
<Circle
cx={size}
cy={size}
r={(size - tickness) / 2}
strokeWidth={tickness}
fill="none"
stroke={color ?? theme.accent}
strokeDasharray={[px(80), px(200)]}
{...css(
Platform.OS === "web" && {
// @ts-ignore Web only
animation: "circularProgress-circle 1.4s ease-in-out infinite",
},
)}
/>
</Svg>
</View>
);
};
+51
View File
@@ -0,0 +1,51 @@
/*
* 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 ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import { Button } from "./button";
import { Icon } from "./icons";
import { Menu } from "./menu";
export const Select = <Value extends string>({
label,
value,
onValueChange,
values,
getLabel,
}: {
label: string;
value: Value;
onValueChange: (v: Value) => void;
values: Value[];
getLabel: (key: Value) => string;
}) => {
return (
<Menu Trigger={Button} text={getLabel(value)} icon={<Icon icon={ExpandMore} />}>
{values.map((x) => (
<Menu.Item
key={x}
label={getLabel(x)}
selected={x === value}
onSelect={() => onValueChange(x)}
/>
))}
</Menu>
);
};
+176
View File
@@ -0,0 +1,176 @@
/*
* 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 Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import ExpandMore from "@material-symbols/svg-400/rounded/keyboard_arrow_down-fill.svg";
import ExpandLess from "@material-symbols/svg-400/rounded/keyboard_arrow_up-fill.svg";
import * as RSelect from "@radix-ui/react-select";
import { forwardRef } from "react";
import { View } from "react-native";
import { useYoshiki } from "yoshiki";
import { type Theme, px, useYoshiki as useNativeYoshiki } from "yoshiki/native";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";
import { InternalTriger, YoshikiProvider } from "./menu.web";
import { P } from "./text";
import { ContrastArea, SwitchVariant } from "./themes";
import { focusReset, ts } from "./utils";
export const Select = ({
label,
value,
onValueChange,
values,
getLabel,
}: {
label: string;
value: string;
onValueChange: (v: string) => void;
values: string[];
getLabel: (key: string) => string;
}) => {
const { css: wCss } = useYoshiki();
const { css } = useNativeYoshiki();
return (
<RSelect.Root value={value} onValueChange={onValueChange}>
<RSelect.Trigger aria-label={label} asChild>
<InternalTriger
Component={PressableFeedback}
ComponentProps={css({
flexGrow: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
p: ts(0.5),
borderRadius: ts(5),
borderColor: (theme) => theme.accent,
borderWidth: ts(0.5),
fover: {
self: { bg: (theme: Theme) => theme.accent },
text: { color: (theme: Theme) => theme.colors.white },
},
})}
>
<View
{...css({
paddingX: ts(3),
flexDirection: "row",
alignItems: "center",
})}
>
<P {...css({ textAlign: "center" }, "text")}>{<RSelect.Value />}</P>
<RSelect.Icon {...wCss({ display: "flex", justifyContent: "center" })}>
<Icon icon={ExpandMore} />
</RSelect.Icon>
</View>
</InternalTriger>
</RSelect.Trigger>
<ContrastArea mode="user">
<SwitchVariant>
<YoshikiProvider>
{({ css }) => (
<RSelect.Portal>
<RSelect.Content
{...css({
bg: (theme) => theme.background,
overflow: "auto",
minWidth: "220px",
borderRadius: "8px",
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
zIndex: 2,
maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)",
})}
>
<RSelect.ScrollUpButton>
<Icon icon={ExpandLess} />
</RSelect.ScrollUpButton>
<RSelect.Viewport>
{values.map((x) => (
<Item key={x} label={getLabel(x)} value={x} />
))}
</RSelect.Viewport>
<RSelect.ScrollDownButton>
<Icon icon={ExpandMore} />
</RSelect.ScrollDownButton>
</RSelect.Content>
</RSelect.Portal>
)}
</YoshikiProvider>
</SwitchVariant>
</ContrastArea>
</RSelect.Root>
);
};
const Item = forwardRef<HTMLDivElement, { label: string; value: string }>(function Item(
{ label, value, ...props },
ref,
) {
const { css, theme } = useYoshiki();
const { css: nCss } = useNativeYoshiki();
return (
<>
<style jsx global>{`
[data-highlighted] {
background: ${theme.variant.accent};
}
`}</style>
<RSelect.Item
ref={ref}
value={value}
{...css(
{
display: "flex",
alignItems: "center",
paddingTop: "8px",
paddingBottom: "8px",
paddingLeft: "35px",
paddingRight: "25px",
height: "32px",
color: (theme) => theme.paragraph,
borderRadius: "4px",
position: "relative",
userSelect: "none",
...focusReset,
},
props as any,
)}
>
<RSelect.ItemText {...css({ color: (theme) => theme.paragraph })}>{label}</RSelect.ItemText>
<RSelect.ItemIndicator asChild>
<InternalTriger
Component={Icon}
icon={Check}
ComponentProps={nCss({
position: "absolute",
left: 0,
width: px(25),
alignItems: "center",
justifyContent: "center",
})}
/>
</RSelect.ItemIndicator>
</RSelect.Item>
</>
);
});
+160
View File
@@ -0,0 +1,160 @@
/*
* 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 { LinearGradient as LG } from "expo-linear-gradient";
import { useEffect } from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withDelay,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
const LinearGradient = Animated.createAnimatedComponent(LG);
export const SkeletonCss = () => (
<style jsx global>{`
@keyframes skeleton {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
`}</style>
);
export const Skeleton = ({
children,
show: forcedShow,
lines = 1,
variant = "text",
...props
}: Omit<ViewProps, "children"> & {
children?: JSX.Element | JSX.Element[] | boolean | null;
show?: boolean;
lines?: number;
variant?: "text" | "header" | "round" | "custom" | "fill" | "filltext";
}) => {
const { css, theme } = useYoshiki();
const width = useSharedValue(-900);
const mult = useSharedValue(-1);
const animated = useAnimatedStyle(() => ({
transform: [
{
translateX: width.value * mult.value,
},
],
}));
useEffect(() => {
mult.value = withRepeat(withDelay(800, withTiming(1, { duration: 800 })), 0);
});
if (forcedShow === undefined && children && children !== true) return <>{children}</>;
return (
<View
{...css(
[
{
position: "relative",
},
lines === 1 && { overflow: "hidden", borderRadius: px(6) },
(variant === "text" || variant === "header") &&
lines === 1 && [
{
width: percent(75),
height: rem(1.2),
margin: px(2),
},
variant === "text" && {
margin: px(2),
},
variant === "header" && {
marginBottom: rem(0.5),
},
],
variant === "round" && {
borderRadius: 9999999,
},
variant === "fill" && {
width: percent(100),
height: percent(100),
},
variant === "filltext" && {
width: percent(100),
height: em(1),
},
],
props,
)}
>
{(forcedShow || !children || children === true) &&
[...Array(lines)].map((_, i) => (
<View
key={`skeleton_${i}`}
onLayout={(e) => {
width.value = e.nativeEvent.layout.width;
}}
{...css([
{
bg: (theme) => theme.overlay0,
},
lines === 1 && {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
lines !== 1 && {
width: i === lines - 1 ? percent(40) : percent(100),
height: rem(1.2),
marginBottom: rem(0.5),
overflow: "hidden",
borderRadius: px(6),
},
])}
>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={["transparent", theme.overlay1, "transparent"]}
style={[
StyleSheet.absoluteFillObject,
{ transform: [{ translateX: -width.value }] },
animated,
]}
/>
</View>
))}
{children}
</View>
);
};
+138
View File
@@ -0,0 +1,138 @@
/*
* 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 { LinearGradient } from "expo-linear-gradient";
import { View, type ViewProps } from "react-native";
import { em, percent, px, rem, useYoshiki } from "yoshiki/native";
export const SkeletonCss = () => (
<style jsx global>{`
@keyframes skeleton {
0% {
transform: translateX(-100%);
}
50% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
`}</style>
);
export const Skeleton = ({
children,
show: forcedShow,
lines = 1,
variant = "text",
...props
}: Omit<ViewProps, "children"> & {
children?: JSX.Element | JSX.Element[] | boolean | null;
show?: boolean;
lines?: number;
variant?: "text" | "header" | "round" | "custom" | "fill" | "filltext";
}) => {
const { css, theme } = useYoshiki();
if (forcedShow === undefined && children && children !== true) return <>{children}</>;
return (
<View
{...css(
[
lines === 1 && { overflow: "hidden", borderRadius: px(6) },
(variant === "text" || variant === "header") &&
lines === 1 && [
{
width: percent(75),
height: rem(1.2),
margin: px(2),
},
variant === "text" && {
margin: px(2),
},
variant === "header" && {
marginBottom: rem(0.5),
},
],
variant === "round" && {
borderRadius: 9999999,
},
variant === "fill" && {
width: percent(100),
height: percent(100),
},
variant === "filltext" && {
width: percent(100),
height: em(1),
},
],
props,
)}
>
{(forcedShow || !children || children === true) &&
[...Array(lines)].map((_, i) => (
<View
key={`skeleton_${i}`}
{...css([
{
bg: (theme) => theme.overlay0,
},
lines === 1 && {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
lines !== 1 && {
width: i === lines - 1 ? percent(40) : percent(100),
height: rem(1.2),
marginBottom: rem(0.5),
overflow: "hidden",
borderRadius: px(6),
},
])}
>
<LinearGradient
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
colors={["transparent", theme.overlay1, "transparent"]}
{...css([
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
// @ts-ignore Web only properties
animation: "skeleton 1.6s linear 0.5s infinite",
transform: "translateX(-100%)",
},
])}
/>
</View>
))}
{children}
</View>
);
};
+215
View File
@@ -0,0 +1,215 @@
/*
* 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 { useRef, useState } from "react";
import { type GestureResponderEvent, Platform, View } from "react-native";
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils";
import { Stylable, percent, px, useYoshiki } from "yoshiki/native";
import { focusReset } from "./utils";
export const Slider = ({
progress,
subtleProgress,
max = 100,
markers,
setProgress,
startSeek,
endSeek,
onHover,
size = 6,
...props
}: {
progress: number;
max?: number;
subtleProgress?: number;
markers?: number[];
setProgress: (progress: number) => void;
startSeek?: () => void;
endSeek?: () => void;
onHover?: (
position: number | null,
layout: { x: number; y: number; width: number; height: number },
) => void;
size?: number;
} & Partial<ViewProps>) => {
const { css } = useYoshiki();
const ref = useRef<View>(null);
const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
const [isSeeking, setSeek] = useState(false);
const [isHover, setHover] = useState(false);
const [isFocus, setFocus] = useState(false);
const smallBar = !(isSeeking || isHover || isFocus);
const ts = (value: number) => px(value * size);
const change = (event: GestureResponderEvent) => {
event.preventDefault();
const locationX = Platform.select({
android: event.nativeEvent.pageX - layout.x,
default: event.nativeEvent.locationX,
});
setProgress(Math.max(0, Math.min(locationX / layout.width, 1)) * max);
};
return (
<View
ref={ref}
// @ts-ignore Web only
onMouseEnter={() => setHover(true)}
// @ts-ignore Web only
onMouseLeave={() => {
setHover(false);
onHover?.(null, layout);
}}
// @ts-ignore Web only
onMouseMove={(e) =>
onHover?.(Math.max(0, Math.min((e.clientX - layout.x) / layout.width, 1) * max), layout)
}
tabIndex={0}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
onStartShouldSetResponder={() => true}
onResponderGrant={() => {
setSeek(true);
startSeek?.call(null);
}}
onResponderRelease={() => {
setSeek(false);
endSeek?.call(null);
}}
onResponderStart={change}
onResponderMove={change}
onLayout={() =>
ref.current?.measure((_, __, width, height, pageX, pageY) =>
setLayout({ width, height, x: pageX, y: pageY }),
)
}
onKeyDown={(e: KeyboardEvent) => {
switch (e.code) {
case "ArrowLeft":
setProgress(Math.max(progress - 0.05 * max, 0));
break;
case "ArrowRight":
setProgress(Math.min(progress + 0.05 * max, max));
break;
case "ArrowDown":
setProgress(Math.max(progress - 0.1 * max, 0));
break;
case "ArrowUp":
setProgress(Math.min(progress + 0.1 * max, max));
break;
}
}}
{...css(
{
paddingVertical: ts(1),
// @ts-ignore Web only
cursor: "pointer",
...focusReset,
},
props,
)}
>
<View
{...css([
{
width: percent(100),
height: ts(1),
bg: (theme) => theme.overlay0,
},
smallBar && { transform: "scaleY(0.4)" as any },
])}
>
{subtleProgress !== undefined && (
<View
{...css(
{
bg: (theme) => theme.overlay1,
position: "absolute",
top: 0,
bottom: 0,
left: 0,
},
{
style: {
width: percent((subtleProgress / max) * 100),
},
},
)}
/>
)}
<View
{...css(
{
bg: (theme) => theme.accent,
position: "absolute",
top: 0,
bottom: 0,
left: 0,
},
{
// In an inline style because yoshiki's insertion can not catch up with the constant redraw
style: {
width: percent((progress / max) * 100),
},
},
)}
/>
{markers?.map((x) => (
<View
key={x}
{...css({
position: "absolute",
top: 0,
bottom: 0,
left: percent(Math.min(100, (x / max) * 100)),
bg: (theme) => theme.accent,
width: ts(0.5),
height: ts(1),
})}
/>
))}
</View>
<View
{...css(
[
{
position: "absolute",
top: 0,
bottom: 0,
marginY: ts(0.5),
bg: (theme) => theme.accent,
width: ts(2),
height: ts(2),
borderRadius: ts(1),
marginLeft: ts(-1),
},
smallBar && { opacity: 0 },
],
{
style: {
left: percent((progress / max) * 100),
},
},
)}
/>
</View>
);
};
+115
View File
@@ -0,0 +1,115 @@
/*
* 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 { usePortal } from "@gorhom/portal";
import { type ReactElement, createContext, useCallback, useContext, useRef } from "react";
import { View } from "react-native";
import { percent, px } from "yoshiki/native";
import { Button } from "./button";
import { imageBorderRadius } from "./constants";
import { P } from "./text";
import { SwitchVariant } from "./themes";
import { ts } from "./utils";
export type Snackbar = {
key?: string;
label: string;
duration: number;
actions?: Action[];
};
export type Action = {
label: string;
icon: ReactElement;
action: () => void;
};
const SnackbarContext = createContext<(snackbar: Snackbar) => void>(null!);
export const SnackbarProvider = ({ children }: { children: ReactElement | ReactElement[] }) => {
const { addPortal, removePortal } = usePortal();
const snackbars = useRef<Snackbar[]>([]);
const timeout = useRef<NodeJS.Timeout | null>(null);
const createSnackbar = useCallback(
(snackbar: Snackbar) => {
if (snackbar.key) snackbars.current = snackbars.current.filter((x) => snackbar.key !== x.key);
snackbars.current.unshift(snackbar);
if (timeout.current) return;
const updatePortal = () => {
const top = snackbars.current.pop();
if (!top) {
timeout.current = null;
return;
}
const { key, ...props } = top;
addPortal("snackbar", <Snackbar key={key} {...props} />);
timeout.current = setTimeout(() => {
removePortal("snackbar");
updatePortal();
}, snackbar.duration * 1000);
};
updatePortal();
},
[addPortal, removePortal],
);
return <SnackbarContext.Provider value={createSnackbar}>{children}</SnackbarContext.Provider>;
};
export const useSnackbar = () => {
return useContext(SnackbarContext);
};
const Snackbar = ({ label, actions }: Snackbar) => {
// TODO: take navbar height into account for setting the position of the snacbar.
return (
<SwitchVariant>
{({ css }) => (
<View
{...css({
position: "absolute",
left: 0,
right: 0,
bottom: ts(4),
})}
>
<View
{...css({
bg: (theme) => theme.background,
maxWidth: { sm: percent(75), md: percent(45), lg: px(500) },
margin: ts(1),
padding: ts(1),
flexDirection: "row",
borderRadius: imageBorderRadius,
})}
>
<P {...css({ flexGrow: 1, flexShrink: 1 })}>{label}</P>
{actions?.map((x, i) => (
<Button key={i} text={x.label} icon={x.icon} onPress={x.action} />
))}
</View>
</View>
)}
</SwitchVariant>
);
};
+26
View File
@@ -0,0 +1,26 @@
/*
* 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/>.
*/
declare module "*.svg" {
import type React from "react";
import type { SvgProps } from "react-native-svg";
const content: React.FC<SvgProps>;
export default content;
}
+85
View File
@@ -0,0 +1,85 @@
import {
H1 as EH1,
H2 as EH2,
H3 as EH3,
H4 as EH4,
H5 as EH5,
H6 as EH6,
P as EP,
} from "@expo/html-elements";
import type { ComponentProps, ComponentType } from "react";
import { Platform, type StyleProp, Text, type TextProps, type TextStyle } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import { ts } from "./utils/spacing";
const styleText = (
Component: ComponentType<ComponentProps<typeof EP>>,
type?: "header" | "sub",
custom?: TextStyle,
) => {
const Text = (
props: Omit<ComponentProps<typeof EP>, "style"> & {
style?: StyleProp<TextStyle>;
children?: TextProps["children"];
},
) => {
const { css, theme } = useYoshiki();
return (
<Component
{...css(
[
{
marginVertical: rem(0.5),
color: type === "header" ? theme.heading : theme.paragraph,
flexShrink: 1,
fontSize: rem(1),
fontFamily: theme.font.normal,
},
type === "sub" && {
fontFamily: theme.font["300"] ?? theme.font.normal,
fontWeight: "300",
opacity: 0.8,
fontSize: rem(0.8),
},
custom?.fontWeight && {
fontFamily: theme.font[custom.fontWeight] ?? theme.font.normal,
},
custom,
],
props as TextProps,
)}
/>
);
};
return Text;
};
export const H1 = styleText(EH1, "header", { fontSize: rem(3), fontWeight: "900" });
export const H2 = styleText(EH2, "header", { fontSize: rem(2) });
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, undefined, { fontSize: rem(1) });
export const SubP = styleText(EP, "sub");
export const LI = ({ children, ...props }: TextProps) => {
const { css } = useYoshiki();
return (
<P role={Platform.OS === "web" ? "listitem" : props.role} {...props}>
<Text
{...css({
height: percent(100),
marginBottom: rem(0.5),
paddingRight: ts(1),
})}
>
{String.fromCharCode(0x2022)}
</Text>
{children}
</P>
);
};
+19
View File
@@ -0,0 +1,19 @@
/*
* 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/>.
*/
+22
View File
@@ -0,0 +1,22 @@
/*
* 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/>.
*/
export * from "./theme";
export * from "./catppuccin";
+20
View File
@@ -0,0 +1,20 @@
/*
* 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/>.
*/
+30
View File
@@ -0,0 +1,30 @@
/*
* 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 { ToastAndroid } from "react-native";
export const tooltip = (tooltip: string, up?: boolean) => ({
onLongPress: () => {
ToastAndroid.show(tooltip, ToastAndroid.SHORT);
},
});
import type { Tooltip as RTooltip } from "react-tooltip";
export const Tooltip: typeof RTooltip = (() => null) as any;
+55
View File
@@ -0,0 +1,55 @@
/*
* 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 { forwardRef } from "react";
import { Tooltip as RTooltip } from "react-tooltip";
import type { Theme } from "yoshiki/native";
import { ContrastArea } from "./themes";
export const tooltip = (tooltip: string, up?: boolean) => ({
dataSet: {
tooltipContent: tooltip,
label: tooltip,
tooltipPlace: up ? "top" : "bottom",
tooltipId: "tooltip",
},
});
export const WebTooltip = ({ theme }: { theme: Theme }) => {
return (
<style jsx global>{`
body {
--rt-color-white: ${theme.alternate.contrast};
--rt-color-dark: ${theme.user.contrast};
--rt-opacity: 0.9;
--rt-transition-show-delay: 0.15s;
--rt-transition-closing-delay: 0.15s;
}
`}</style>
);
};
export const Tooltip = forwardRef(function Tooltip(props, ref) {
return (
<ContrastArea mode="alternate">
<RTooltip {...props} ref={ref} />
</ContrastArea>
);
}) as typeof RTooltip;
+57
View File
@@ -0,0 +1,57 @@
/*
* 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 type React from "react";
import "react-native";
declare module "react-native" {
interface PressableStateCallbackType {
hovered?: boolean;
focused?: boolean;
}
interface AccessibilityProps {
tabIndex?: number;
}
interface ViewStyle {
transitionProperty?: string;
transitionDuration?: string;
}
interface TextProps {
hrefAttrs?: {
rel?: "noreferrer";
target?: string;
};
}
interface ViewProps {
dataSet?: Record<string, string>;
hrefAttrs?: {
rel: "noreferrer";
target?: "_blank";
};
onClick?: (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => void;
}
}
declare module "react" {
interface StyleHTMLAttributes<T> extends HTMLAttributes<T> {
jsx?: boolean;
global?: boolean;
}
}
+61
View File
@@ -0,0 +1,61 @@
/*
* 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 { useWindowDimensions } from "react-native";
import { type Breakpoints as YoshikiBreakpoint, breakpoints, isBreakpoints } from "yoshiki/native";
type AtLeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
export type Breakpoint<T> = T | AtLeastOne<YoshikiBreakpoint<T>>;
// copied from yoshiki.
const useBreakpoint = () => {
const { width } = useWindowDimensions();
const idx = Object.values(breakpoints).findIndex((x) => width <= x);
if (idx === -1) return 0;
return idx - 1;
};
const getBreakpointValue = <T>(value: Breakpoint<T>, breakpoint: number): T => {
if (!isBreakpoints(value)) return value;
const bpKeys = Object.keys(breakpoints) as Array<keyof YoshikiBreakpoint<T>>;
for (let i = breakpoint; i >= 0; i--) {
if (bpKeys[i] in value) {
const val = value[bpKeys[i]];
if (val) return val;
}
}
// This should never be reached.
return undefined!;
};
export const useBreakpointValue = <T>(value: Breakpoint<T>): T => {
const breakpoint = useBreakpoint();
return getBreakpointValue(value, breakpoint);
};
export const useBreakpointMap = <T extends Record<string, unknown>>(
value: T,
): { [key in keyof T]: T[key] extends Breakpoint<infer V> ? V : T } => {
const breakpoint = useBreakpoint();
// @ts-ignore
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [key, getBreakpointValue(val, breakpoint)]),
);
};
+32
View File
@@ -0,0 +1,32 @@
/*
* 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/>.
*/
export const capitalize = (str: string): string => {
return str
.split(" ")
.map((s) => s.trim())
.map((s) => {
if (s.length > 1) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
return s;
})
.join(" ");
};
+31
View File
@@ -0,0 +1,31 @@
/*
* 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/>.
*/
export const Head = ({
title,
description,
image,
}: {
title?: string | null;
description?: string | null;
image?: string | null;
}) => {
return null;
};
+39
View File
@@ -0,0 +1,39 @@
/*
* 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 NextHead from "next/head";
export const Head = ({
title,
description,
image,
}: {
title?: string | null;
description?: string | null;
image?: string | null;
}) => {
return (
<NextHead>
{title && <title>{`${title} - Kyoo`}</title>}
{description && <meta name="description" content={description} />}
{image && <meta property="og:image" content={image} />}
</NextHead>
);
};
+27
View File
@@ -0,0 +1,27 @@
/*
* 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/>.
*/
export * from "./breakpoints";
export * from "./nojs";
export * from "./head";
export * from "./spacing";
export * from "./capitalize";
export * from "./touchonly";
export * from "./page-style";
+35
View File
@@ -0,0 +1,35 @@
/*
* 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 type { ViewProps } from "react-native";
export const hiddenIfNoJs: ViewProps = { style: { $$css: true, noJs: "noJsHidden" } as any };
export const HiddenIfNoJs = () => (
<noscript>
<style>
{`
.noJsHidden {
display: none;
}
`}
</style>
</noscript>
);
+26
View File
@@ -0,0 +1,26 @@
/*
* 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 { useSafeAreaInsets } from "react-native-safe-area-context";
export const usePageStyle = () => {
const insets = useSafeAreaInsets();
return { paddingBottom: insets.bottom } as const;
};
@@ -0,0 +1,23 @@
/*
* 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/>.
*/
export const usePageStyle = () => {
return {} as const;
};
+38
View File
@@ -0,0 +1,38 @@
/*
* 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 { Platform } from "react-native";
import { px } from "yoshiki/native";
export const important = <T,>(value: T): T => {
return `${value} !important` as T;
};
export const ts = (spacing: number) => {
return px(spacing * 8);
};
export const focusReset: object =
Platform.OS === "web"
? {
boxShadow: "unset",
outline: "none",
}
: {};
+48
View File
@@ -0,0 +1,48 @@
/*
* 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 { Platform, type ViewProps } from "react-native";
export const TouchOnlyCss = () => {
return (
<style jsx global>{`
:where(body.noHover) .noTouch {
display: none;
}
:where(body:not(.noHover)) .touchOnly {
display: none;
}
`}</style>
);
};
export const touchOnly: ViewProps = {
style: Platform.OS === "web" ? ({ $$css: true, touchOnly: "touchOnly" } as any) : {},
};
export const noTouch: ViewProps = {
style: Platform.OS === "web" ? ({ $$css: true, noTouch: "noTouch" } as any) : { display: "none" },
};
export const useIsTouch = () => {
if (Platform.OS !== "web") return true;
if (typeof window === "undefined") return false;
// TODO: Subscribe to the change.
return document.body.classList.contains("noHover");
};
+2 -40
View File
@@ -1,9 +1,5 @@
import { type ComponentType, type ReactElement, isValidElement } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { P } from "~/primitives";
import { ErrorView } from "./errors";
import type { ReactElement } from "react";
import { ErrorView, OfflineView } from "~/ui/errors";
import { type QueryIdentifier, useFetch } from "./query";
export const Fetch = <Data,>({
@@ -22,37 +18,3 @@ export const Fetch = <Data,>({
if (!data) return <Loader />;
return <Render {...data} />;
};
export const OfflineView = () => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<View
{...css({
flexGrow: 1,
flexShrink: 1,
justifyContent: "center",
alignItems: "center",
})}
>
<P {...css({ color: (theme) => theme.colors.white })}>{t("errors.offline")}</P>
</View>
);
};
export const EmptyView = ({ message }: { message: string }) => {
const { css } = useYoshiki();
return (
<View
{...css({
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
})}
>
<P {...css({ color: (theme) => theme.heading })}>{message}</P>
</View>
);
};
+59
View File
@@ -0,0 +1,59 @@
import { ConnectionErrorContext, useAccount } from "@kyoo/models";
import { Button, H1, Icon, Link, P, ts } from "@kyoo/primitives";
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useRouter } from "solito/router";
import { useYoshiki } from "yoshiki/native";
import { DefaultLayout } from "../../../packages/ui/src/layout";
export const ConnectionError = () => {
const { css } = useYoshiki();
const { t } = useTranslation();
const router = useRouter();
const { error, retry } = useContext(ConnectionErrorContext);
const account = useAccount();
if (error && (error.status === 401 || error.status === 403)) {
if (!account) {
return (
<View
{...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}
>
<P>{t("errors.needAccount")}</P>
<Button
as={Link}
href={"/register"}
text={t("login.register")}
licon={<Icon icon={Register} {...css({ marginRight: ts(2) })} />}
/>
</View>
);
}
if (!account.isVerified) {
return (
<View
{...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}
>
<P>{t("errors.needVerification")}</P>
</View>
);
}
}
return (
<View {...css({ padding: ts(2) })}>
<H1 {...css({ textAlign: "center" })}>{t("errors.connection")}</H1>
<P>{error?.errors[0] ?? t("errors.unknown")}</P>
<P>{t("errors.connection-tips")}</P>
<Button onPress={retry} text={t("errors.try-again")} {...css({ m: ts(1) })} />
<Button
onPress={() => router.push("/login")}
text={t("errors.re-login")}
{...css({ m: ts(1) })}
/>
</View>
);
};
ConnectionError.getLayout = DefaultLayout;
+19
View File
@@ -0,0 +1,19 @@
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { P } from "~/primitives";
export const EmptyView = ({ message }: { message: string }) => {
const { css } = useYoshiki();
return (
<View
{...css({
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
})}
>
<P {...css({ color: (theme) => theme.heading })}>{message}</P>
</View>
);
};
+39
View File
@@ -0,0 +1,39 @@
import { useContext, useLayoutEffect } from "react";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { ConnectionErrorContext, type KyooErrors } from "~/models";
import { P } from "~/primitives";
export const ErrorView = ({
error,
noBubble = false,
}: {
error: KyooErrors;
noBubble?: boolean;
}) => {
const { css } = useYoshiki();
const { setError } = useContext(ConnectionErrorContext);
useLayoutEffect(() => {
// if this is a permission error, make it go up the tree to have a whole page login screen.
if (!noBubble && (error.status === 401 || error.status === 403)) setError(error);
}, [error, noBubble, setError]);
console.log(error);
return (
<View
{...css({
backgroundColor: (theme) => theme.colors.red,
flexGrow: 1,
flexShrink: 1,
justifyContent: "center",
alignItems: "center",
})}
>
{error.errors.map((x, i) => (
<P key={i} {...css({ color: (theme) => theme.colors.white })}>
{x}
</P>
))}
</View>
);
};
+6
View File
@@ -0,0 +1,6 @@
export * from "./error";
export * from "./unauthorized";
export * from "./connection";
export * from "./setup";
export * from "./empty";
export * from "./offline";
+22
View File
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { P } from "~/primitives";
export const OfflineView = () => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<View
{...css({
flexGrow: 1,
flexShrink: 1,
justifyContent: "center",
alignItems: "center",
})}
>
<P {...css({ color: (theme) => theme.colors.white })}>{t("errors.offline")}</P>
</View>
);
};
+48
View File
@@ -0,0 +1,48 @@
import { Main } from "@expo/html-elements";
import { type QueryPage, SetupStep } from "@kyoo/models";
import { Button, Icon, Link, P, ts } from "@kyoo/primitives";
import Register from "@material-symbols/svg-400/rounded/app_registration.svg";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "solito/router";
import { useYoshiki } from "yoshiki/native";
import { Navbar, NavbarProfile } from "../../../packages/ui/src/navbar";
import { KyooLongLogo } from "../../../packages/ui/src/navbar/icon";
export const SetupPage: QueryPage<{ step: SetupStep }> = ({ step }) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const router = useRouter();
const isValid = Object.values(SetupStep).includes(step) && step !== SetupStep.Done;
useEffect(() => {
if (!isValid) router.replace("/");
}, [isValid, router]);
if (!isValid) return <P>Loading...</P>;
return (
<Main {...css({ flexGrow: 1, flexShrink: 1, justifyContent: "center", alignItems: "center" })}>
<P>{t(`errors.setup.${step}`)}</P>
{step === SetupStep.MissingAdminAccount && (
<Button
as={Link}
href={"/register"}
text={t("login.register")}
licon={<Icon icon={Register} {...css({ marginRight: ts(2) })} />}
/>
)}
</Main>
);
};
SetupPage.getLayout = ({ page }) => {
const { css } = useYoshiki();
return (
<>
<Navbar left={<KyooLongLogo {...css({ marginX: ts(2) })} />} right={<NavbarProfile />} />
{page}
</>
);
};
+22
View File
@@ -0,0 +1,22 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { P } from "~/primitives";
export const Unauthorized = ({ missing }: { missing: string[] }) => {
const { t } = useTranslation();
const { css } = useYoshiki();
return (
<View
{...css({
flexGrow: 1,
flexShrink: 1,
justifyContent: "center",
alignItems: "center",
})}
>
<P>{t("errors.unauthorized", { permission: missing?.join(", ") })}</P>
</View>
);
};