Rework images & skeletons

This commit is contained in:
Zoe Roux 2025-06-22 17:39:19 +02:00
parent 886b33d5a7
commit e63e3605c6
No known key found for this signature in database
34 changed files with 289 additions and 1181 deletions

View File

@ -7,15 +7,13 @@ import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { Platform } from "react-native";
import { useYoshiki } from "yoshiki/native";
import type { Serie } from "~/models";
import { WatchStatusV } from "~/models";
import { HR, IconButton, Menu, tooltip } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query";
import { watchListIcon } from "./watchlist-info";
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
type WatchStatusV = NonNullable<Serie["watchStatus"]>["status"];
export const EpisodesContext = ({
type = "episode",
slug,

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import { type ImageStyle, Platform, View } from "react-native";
import { type Stylable, type Theme, percent, px, useYoshiki } from "yoshiki/native";
import type { KyooImage, WatchStatusV } from "~/models";
import type { KImage, WatchStatusV } from "~/models";
import {
Link,
P,
@ -15,6 +15,7 @@ import {
} from "~/primitives";
import type { Layout } from "~/query";
import { ItemWatchStatus } from "./item-helpers";
import { ItemContext } from "./context-menus";
export const ItemProgress = ({ watchPercent }: { watchPercent: number }) => {
const { css } = useYoshiki("episodebox");
@ -59,7 +60,7 @@ export const ItemGrid = ({
slug: string;
name: string;
subtitle: string | null;
poster: KyooImage | null;
poster: KImage | null;
watchStatus: WatchStatusV | null;
watchPercent: number | null;
type: "movie" | "serie" | "collection";

View File

@ -1,6 +1,6 @@
import { z } from "zod";
import { z } from "zod/v4";
import { Genre } from "./utils/genre";
import { Image } from "./utils/images";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
@ -24,10 +24,10 @@ export const Collection = z
genres: z.array(Genre),
externalId: Metadata,
poster: Image.nullable(),
thumbnail: Image.nullable(),
banner: Image.nullable(),
logo: Image.nullable(),
poster: KImage.nullable(),
thumbnail: KImage.nullable(),
banner: KImage.nullable(),
logo: KImage.nullable(),
createdAt: zdate(),
updatedAt: zdate(),

View File

@ -1,4 +1,4 @@
import z from "zod";
import { z } from "zod/v4";
export const Entry = z.object({
id: z.string(),

View File

@ -8,3 +8,5 @@ export * from "./show";
export * from "./entry";
export * from "./studio";
export * from "./video";
export * from "./utils/images";

View File

@ -1,7 +1,7 @@
import { z } from "zod";
import { z } from "zod/v4";
import { Studio } from "./studio";
import { Genre } from "./utils/genre";
import { Image } from "./utils/images";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
import { EmbeddedVideo } from "./video";
@ -27,10 +27,10 @@ export const Movie = z
genres: z.array(Genre),
externalId: Metadata,
poster: Image.nullable(),
thumbnail: Image.nullable(),
banner: Image.nullable(),
logo: Image.nullable(),
poster: KImage.nullable(),
thumbnail: KImage.nullable(),
banner: KImage.nullable(),
logo: KImage.nullable(),
trailerUrl: z.string().optional().nullable(),
isAvailable: z.boolean(),

View File

@ -1,8 +1,8 @@
import { z } from "zod";
import { z } from "zod/v4";
import { Entry } from "./entry";
import { Studio } from "./studio";
import { Genre } from "./utils/genre";
import { Image } from "./utils/images";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
@ -28,10 +28,10 @@ export const Serie = z
runtime: z.number().nullable(),
externalId: Metadata,
poster: Image.nullable(),
thumbnail: Image.nullable(),
banner: Image.nullable(),
logo: Image.nullable(),
poster: KImage.nullable(),
thumbnail: KImage.nullable(),
banner: KImage.nullable(),
logo: KImage.nullable(),
trailerUrl: z.string().optional().nullable(),
entriesCount: z.number().int(),

View File

@ -1,4 +1,4 @@
import z from "zod";
import { z } from "zod/v4";
import { Collection } from "./collection";
import { Movie } from "./movie";
import { Serie } from "./serie";
@ -9,3 +9,6 @@ export const Show = z.union([
Collection.and(z.object({ kind: z.literal("collection") })),
]);
export type Show = z.infer<typeof Show>;
export type WatchStatusV = NonNullable<Serie["watchStatus"]>["status"];
export const WatchStatusV = ["completed", "watching", "rewatching", "dropped", "planned"] as const;

View File

@ -1,5 +1,5 @@
import { z } from "zod";
import { Image } from "./utils/images";
import { z } from "zod/v4";
import { KImage } from "./utils/images";
import { Metadata } from "./utils/metadata";
import { zdate } from "./utils/utils";
@ -7,7 +7,7 @@ export const Studio = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
logo: Image.nullable(),
logo: KImage.nullable(),
externalId: Metadata,
createdAt: zdate(),
updatedAt: zdate(),

View File

@ -1,4 +1,4 @@
import z from "zod";
import z from "zod/v4";
export const Genre = z.enum([
"action",

View File

@ -1,6 +1,6 @@
import { z } from "zod";
import { z } from "zod/v4";
export const Image = z
export const KImage = z
.object({
id: z.string(),
source: z.string(),
@ -13,4 +13,4 @@ export const Image = z
high: `/images/${x.id}?quality=high`,
}));
export type Image = z.infer<typeof Image>;
export type KImage = z.infer<typeof KImage>;

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import { z } from "zod/v4";
export const Metadata = z.record(
z.string(),
z.object({
dataId: z.string(),
link: z.string().nullable(),

View File

@ -1,20 +1,16 @@
import { z } from "zod";
import { type ZodType, z } from "zod/v4";
export interface Page<T> {
export type Page<T> = {
this: string;
first: string;
next: string | null;
count: number;
items: T[];
}
};
export const Paged = <Item>(item: z.ZodType<Item>): z.ZodSchema<Page<Item>> =>
export const Paged = <Parser extends ZodType>(ItemParser: Parser) =>
z.object({
this: z.string(),
first: z.string(),
next: z.string().nullable(),
count: z.number(),
items: z.array(item),
items: z.array(ItemParser),
});
export const isPage = <T = unknown>(obj: unknown): obj is Page<T> =>

View File

@ -1,3 +1,3 @@
import { z } from "zod";
import { z } from "zod/v4";
export const zdate = z.coerce.date;

View File

@ -1,4 +1,4 @@
import { z } from "zod";
import { z } from "zod/v4";
export const EmbeddedVideo = z.object({
id: z.string(),

View File

@ -1 +0,0 @@
export const imageBorderRadius = 10;

View File

@ -0,0 +1,95 @@
import { ImageBackground as EImageBackground } from "expo-image";
import { LinearGradient, type LinearGradientProps } from "expo-linear-gradient";
import type { ComponentProps, ReactNode } from "react";
import type { ImageStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import type { KImage } from "~/models";
import { useToken } from "~/providers/account-context";
import type { ImageLayout, YoshikiEnhanced } from "./image";
// This should stay in think with `Image`.
// (copy-pasted but change `EImage` with `EImageBackground`)
// ALSO, remove `border-radius` (it's weird otherwise)
export const ImageBackground = ({
src,
quality,
alt,
layout,
...props
}: {
src: KImage | null;
quality: "low" | "medium" | "high";
alt?: string;
style?: ImageStyle;
layout: ImageLayout;
children: ReactNode;
}) => {
const { css } = useYoshiki();
const { apiUrl, authToken } = useToken();
return (
<EImageBackground
source={{
uri: src ? `${apiUrl}${src[quality ?? "high"]}` : null,
headers: authToken
? {
Authorization: authToken,
}
: {},
}}
placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt}
{...(css(layout, props) as any)}
/>
);
};
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: 6 }, props)}
/>
);
};
export const GradientImageBackground = ({
gradient,
children,
...props
}: ComponentProps<typeof ImageBackground> & {
gradient?: Partial<LinearGradientProps>;
}) => {
const { css, theme } = useYoshiki();
return (
<ImageBackground {...props}>
<LinearGradient
start={{ x: 0, y: 0.25 }}
end={{ x: 0, y: 1 }}
colors={["transparent", theme.darkOverlay]}
{...css(
{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
},
typeof gradient === "object" ? gradient : undefined,
)}
>
{children}
</LinearGradient>
</ImageBackground>
);
};

View File

@ -0,0 +1,76 @@
import { Image as EImage } from "expo-image";
import type { ComponentProps } from "react";
import type { ImageStyle, ViewStyle } from "react-native";
import { useYoshiki } from "yoshiki/native";
import type { YoshikiStyle } from "yoshiki/src/type";
import type { KImage } from "~/models";
import { useToken } from "~/providers/account-context";
import { Skeleton } from "./skeleton";
export type YoshikiEnhanced<Style> = Style extends any
? {
[key in keyof Style]: YoshikiStyle<Style[key]>;
}
: never;
export type ImageLayout = YoshikiEnhanced<
| { width: ImageStyle["width"]; height: ImageStyle["height"] }
| { width: ImageStyle["width"]; aspectRatio: ImageStyle["aspectRatio"] }
| { height: ImageStyle["height"]; aspectRatio: ImageStyle["aspectRatio"] }
>;
// This should stay in think with `ImageBackground`.
// (copy-pasted but change `EImageBackground` with `EImage`)
export const Image = ({
src,
quality,
alt,
layout,
...props
}: {
src: KImage | null;
quality: "low" | "medium" | "high";
alt?: string;
style?: ImageStyle;
layout: ImageLayout;
}) => {
const { css } = useYoshiki();
const { apiUrl, authToken } = useToken();
return (
<EImage
source={{
uri: src ? `${apiUrl}${src[quality ?? "high"]}` : null,
headers: authToken
? {
Authorization: authToken,
}
: {},
}}
placeholder={{ blurhash: src?.blurhash }}
accessibilityLabel={alt}
{...(css([layout, { borderRadius: 6 }], props) as any)}
/>
);
};
Image.Loader = ({ layout, ...props }: { layout: ImageLayout }) => {
const { css } = useYoshiki();
const border = { borderRadius: 6 } satisfies ViewStyle;
return <Skeleton variant="custom" {...css([layout, border], props)} />;
};
export const Poster = ({
layout,
...props
}: ComponentProps<typeof Image> & {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <Image layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;
Poster.Loader = ({
layout,
...props
}: {
layout: YoshikiEnhanced<{ width: ImageStyle["width"] } | { height: ImageStyle["height"] }>;
}) => <Image.Loader layout={{ aspectRatio: 2 / 3, ...layout }} {...props} />;

View File

@ -1,44 +0,0 @@
/*
* 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"] }
>;

View File

@ -1,43 +0,0 @@
/*
* 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>
);
};

View File

@ -1,322 +0,0 @@
/*
* 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>
);
};

View File

@ -1,102 +0,0 @@
/*
* 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)} />;
};

View File

@ -1,89 +0,0 @@
/*
* 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)} />;
};

View File

@ -1,170 +0,0 @@
/*
* 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)}
/>
);
};

View File

@ -1,56 +0,0 @@
/*
* 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}
/>
);
};

View File

@ -4,8 +4,9 @@ export * from "./theme";
export * from "./icons";
export * from "./links";
export * from "./avatar";
// export * from "./image";
// export * from "./skeleton";
export * from "./image";
export * from "./image-background";
export * from "./skeleton";
export * from "./tooltip";
// export * from "./container";
export * from "./divider";
@ -21,4 +22,3 @@ export * from "./button";
// export * from "./chip";
export * from "./utils";
export * from "./constants";

View File

@ -1,23 +1,3 @@
/*
* 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";
@ -32,31 +12,11 @@ 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";
}) => {
@ -75,8 +35,6 @@ export const Skeleton = ({
mult.value = withRepeat(withDelay(800, withTiming(1, { duration: 800 })), 0);
});
if (forcedShow === undefined && children && children !== true) return <>{children}</>;
return (
<View
{...css(
@ -115,46 +73,44 @@ export const Skeleton = ({
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}
{[...Array(lines)].map((_, i) => (
<View
key={`skeleton_${i}`}
onLayout={(e) => {
if (i === 0) 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>
))}
</View>
);
};

View File

@ -1,138 +0,0 @@
/*
* 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>
);
};

View File

@ -1,57 +0,0 @@
/*
* 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;
}
}

View File

@ -9,6 +9,11 @@ export const AccountContext = createContext<{
accounts: (Account & { select: () => void; remove: () => void })[];
}>({ apiUrl: "api", authToken: null, selectedAccount: null, accounts: [] });
export const useToken = () => {
const { apiUrl, authToken } = useContext(AccountContext);
return { apiUrl, authToken };
};
export const useAccount = () => {
const { selectedAccount } = useContext(AccountContext);
return selectedAccount;

View File

@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { z } from "zod";
import { z } from "zod/v4";
import { AccountP, UserP } from "~/models";
import { useFetch } from "~/query";
import { AccountContext } from "./account-context";

View File

@ -1,5 +1,5 @@
import { Platform } from "react-native";
import { z } from "zod";
import { z } from "zod/v4";
import { type Account, AccountP } from "~/models";
import { readValue, setCookie, storeValue } from "./settings";

View File

@ -1,6 +1,6 @@
import { Platform } from "react-native";
import { MMKV, useMMKVString } from "react-native-mmkv";
import type { ZodTypeAny, z } from "zod";
import type { z, ZodType } from "zod/v4";
import { getServerData } from "~/utils";
export const storage = new MMKV();
@ -24,7 +24,7 @@ export const setCookie = (key: string, val?: unknown) => {
document.cookie = `${key}=${value};${expires};path=/;samesite=strict`;
};
export const readCookie = <T extends ZodTypeAny>(key: string, parser: T) => {
export const readCookie = <T extends ZodType>(key: string, parser: T) => {
const cookies = getServerData("cookies");
console.log("cookies", cookies);
const decodedCookie = decodeURIComponent(cookies);
@ -37,7 +37,7 @@ export const readCookie = <T extends ZodTypeAny>(key: string, parser: T) => {
return parser.parse(JSON.parse(str)) as z.infer<T>;
};
export const useStoreValue = <T extends ZodTypeAny>(key: string, parser: T) => {
export const useStoreValue = <T extends ZodType>(key: string, parser: T) => {
if (Platform.OS === "web" && typeof window === "undefined") {
return readCookie(key, parser);
}
@ -50,7 +50,7 @@ export const storeValue = (key: string, value: unknown) => {
storage.set(key, JSON.stringify(value));
};
export const readValue = <T extends ZodTypeAny>(key: string, parser: T) => {
export const readValue = <T extends ZodType>(key: string, parser: T) => {
if (Platform.OS === "web" && typeof window === "undefined") {
return readCookie(key, parser);
}

View File

@ -1,6 +1,6 @@
import {
QueryClient,
dehydrate,
QueryClient,
useInfiniteQuery,
useQuery,
useQueryClient,
@ -8,12 +8,12 @@ import {
} from "@tanstack/react-query";
import { useContext } from "react";
import { Platform } from "react-native";
import type { z } from "zod";
import type { z } from "zod/v4";
import { type KyooError, type Page, Paged } from "~/models";
import { AccountContext } from "~/providers/account-context";
import { setServerData } from "~/utils";
const ssrApiUrl = process.env.KYOO_URL ?? "http://back/api";
const ssrApiUrl = process.env.KYOO_URL ?? "http://api:3567/api";
const cleanSlash = (str: string | null, keepFirst = false) => {
if (!str) return null;
@ -71,9 +71,9 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
throw data as KyooError;
}
if (resp.status === 204) return null;
if (resp.status === 204) return null!;
if (context.plainText) return (await resp.text()) as unknown;
if (context.plainText) return (await resp.text()) as any;
let data: Record<string, any>;
try {
@ -82,7 +82,7 @@ const queryFn = async <Parser extends z.ZodTypeAny>(context: {
console.error("Invalid json from kyoo", e);
throw { message: "Invalid response from kyoo", status: "json" } as KyooError;
}
if (!context.parser) return data;
if (!context.parser) return data as any;
const parsed = await context.parser.safeParseAsync(data);
if (!parsed.success) {
console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error);
@ -108,11 +108,11 @@ export const createQueryClient = () =>
},
});
export type QueryIdentifier<T = unknown, Ret = T> = {
parser: z.ZodType<T, z.ZodTypeDef, any>;
export type QueryIdentifier<T = unknown> = {
parser: z.ZodType<T>;
path: (string | undefined)[];
params?: { [query: string]: boolean | number | string | string[] | undefined };
infinite?: boolean | { value: true; map?: (x: any[]) => Ret[] };
infinite?: boolean;
placeholderData?: T | (() => T);
enabled?: boolean;
@ -143,7 +143,8 @@ export const keyToUrl = (key: ReturnType<typeof toQueryKey>) => {
};
export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
const { apiUrl, authToken } = useContext(AccountContext);
let { apiUrl, authToken } = useContext(AccountContext);
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
return useQuery<Data, KyooError>({
@ -155,17 +156,18 @@ export const useFetch = <Data,>(query: QueryIdentifier<Data>) => {
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
}),
}) as Promise<Data>,
placeholderData: query.placeholderData as any,
enabled: query.enabled,
});
};
export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) => {
const { apiUrl, authToken } = useContext(AccountContext);
export const useInfiniteFetch = <Data,>(query: QueryIdentifier<Data>) => {
let { apiUrl, authToken } = useContext(AccountContext);
if (query.options?.apiUrl) apiUrl = query.options.apiUrl;
const key = toQueryKey({ apiUrl, path: query.path, params: query.params });
const ret = useInfiniteQuery<Page<Data>, KyooError>({
const res = useInfiniteQuery<Page<Data>, KyooError>({
queryKey: key,
queryFn: (ctx) =>
queryFn({
@ -174,20 +176,15 @@ export const useInfiniteFetch = <Data, Ret>(query: QueryIdentifier<Data, Ret>) =
signal: ctx.signal,
authToken: authToken ?? null,
...query.options,
}),
}) as Promise<Page<Data>>,
getNextPageParam: (page: Page<Data>) => page?.next || undefined,
initialPageParam: undefined,
placeholderData: query.placeholderData as any,
enabled: query.enabled,
});
const items = ret.data?.pages.flatMap((x) => x.items);
return {
...ret,
items:
items && typeof query.infinite === "object" && query.infinite.map
? query.infinite.map(items)
: (items as unknown as Ret[] | undefined),
};
const ret = res as typeof res & { items?: Data[] };
ret.items = ret.data?.pages.flatMap((x) => x.items);
return ret;
};
export const prefetch = async (...queries: QueryIdentifier[]) => {
@ -242,7 +239,7 @@ type MutationParams = {
body?: object;
};
export const useMutation = <T = void,>({
export const useMutation = <T = void>({
compute,
invalidate,
...queryParams