5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
bdfef4c6bb Remove old home directory from packages/ui
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 17:03:13 +00:00
copilot-swe-agent[bot]
c7c3cd720b Fix null href handling and date formatting in home components
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 17:02:46 +00:00
copilot-swe-agent[bot]
e6859a1fff Fix imports and model field references in home components
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 17:00:54 +00:00
copilot-swe-agent[bot]
cc60dece54 Migrate home page components from v4 to new structure
Co-authored-by: zoriya <32224410+zoriya@users.noreply.github.com>
2025-12-19 16:56:35 +00:00
copilot-swe-agent[bot]
7d2e050ed6 Initial plan 2025-12-19 16:40:38 +00:00
9 changed files with 192 additions and 232 deletions

View File

@@ -1,34 +1,4 @@
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { Show } from "~/models";
import { P } from "~/primitives";
import { Fetch, prefetch, type QueryIdentifier } from "~/query";
import { HomePage, loader } from "~/ui/home";
export async function loader() {
await prefetch(Header.query());
}
export default function Header() {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<View>
<P>{t("home.recommended")}</P>
<Fetch
query={Header.query()}
Render={({ name }) => <P {...(css({ bg: "red" }) as any)}>{name}</P>}
Loader={() => <P>Loading</P>}
/>
</View>
);
}
Header.query = (): QueryIdentifier<Show> => ({
parser: Show,
path: ["shows", "random"],
params: {
fields: ["firstEntry"],
},
});
export { loader };
export default HomePage;

View File

@@ -33,6 +33,27 @@ const Base = z.object({
playedDate: zdate().nullable(),
videoId: z.string().nullable(),
}),
// Optional fields for API responses
serie: z
.object({
id: z.string(),
slug: z.string(),
name: z.string(),
})
.optional(),
watchStatus: z
.object({
status: z.enum([
"completed",
"watching",
"rewatching",
"dropped",
"planned",
]),
percent: z.number().int().gte(0).lte(100),
})
.nullable()
.optional(),
});
export const Episode = Base.extend({

View File

@@ -18,21 +18,14 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type Genre,
type LibraryItem,
LibraryItemP,
type QueryIdentifier,
useInfiniteFetch,
} from "@kyoo/models";
import { H3, ts } from "@kyoo/primitives";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { itemMap } from "../browse";
import { ItemGrid } from "../browse/grid";
import { InfiniteFetchList } from "../fetch-infinite";
import { ItemGrid, itemMap } from "~/components/items";
import type { Genre, Show } from "~/models";
import { H3, ts } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
export const Header = ({ title }: { title: string }) => {
const { css } = useYoshiki();
@@ -48,32 +41,19 @@ export const Header = ({ title }: { title: string }) => {
})}
>
<H3>{title}</H3>
{/* <View {...css({ flexDirection: "row" })}> */}
{/* <IconButton */}
{/* icon={ChevronLeft} */}
{/* // onPress={() => ref.current?.scrollTo({ x: 0, animated: true })} */}
{/* /> */}
{/* <IconButton */}
{/* icon={ChevronRight} */}
{/* // onPress={() => ref.current?.scrollTo({ x: 0, animated: true })} */}
{/* /> */}
{/* </View> */}
</View>
);
};
export const GenreGrid = ({ genre }: { genre: Genre }) => {
const query = useInfiniteFetch(GenreGrid.query(genre));
const displayEmpty = useRef(false);
const { t } = useTranslation();
return (
<>
{(displayEmpty.current || query.items?.length !== 0) && (
<Header title={t(`genres.${genre}`)} />
)}
<InfiniteFetchList
query={query}
<Header title={t(`genres.${genre}`)} />
<InfiniteFetch
query={GenreGrid.query(genre)}
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
placeholderCount={2}
empty={displayEmpty.current ? t("home.none") : undefined}
@@ -84,15 +64,15 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
);
};
GenreGrid.query = (genre: Genre): QueryIdentifier<LibraryItem> => ({
parser: LibraryItemP,
GenreGrid.query = (genre: Genre): QueryIdentifier<Show> => ({
parser: Show,
infinite: true,
path: ["items"],
path: ["api", "shows"],
params: {
fields: ["watchStatus", "episodesCount"],
fields: ["watchStatus"],
filter: `genres has ${genre}`,
sortBy: "random",
// Limit the inital numbers of items
sort: "random",
// Limit the initial numbers of items
limit: 10,
},
});

View File

@@ -18,45 +18,45 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type KyooImage, type LibraryItem, LibraryItemP, type QueryIdentifier } from "@kyoo/models";
import Info from "@material-symbols/svg-400/rounded/info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import type { KImage, Show } from "~/models";
import {
GradientImageBackground,
H1,
H2,
IconButton,
IconFab,
ImageBackground,
Link,
P,
Skeleton,
tooltip,
ts,
} from "@kyoo/primitives";
import Info from "@material-symbols/svg-400/rounded/info.svg";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { percent, rem, useYoshiki } from "yoshiki/native";
import { Header as DetailsHeader } from "../../../../src/ui/details/header";
import type { WithLoading } from "../fetch";
} from "~/primitives";
import { type QueryIdentifier } from "~/query";
import { Header as DetailsHeader } from "../details/header";
export const Header = ({
isLoading,
name,
thumbnail,
overview,
description,
tagline,
link,
infoLink,
...props
}: WithLoading<{
}: {
isLoading?: boolean;
name: string;
thumbnail: KyooImage | null;
overview: string | null;
thumbnail: KImage | null;
description: string | null;
tagline: string | null;
link: string | null;
infoLink: string;
}>) => {
}) => {
const { css } = useYoshiki();
const { t } = useTranslation();
@@ -105,7 +105,7 @@ export const Header = ({
<Skeleton lines={4} {...css({ marginTop: ts(1) })}>
{isLoading || (
<P numberOfLines={4} {...css({ display: { xs: "none", md: "flex" } })}>
{overview}
{description}
</P>
)}
</Skeleton>
@@ -114,10 +114,10 @@ export const Header = ({
);
};
Header.query = (): QueryIdentifier<LibraryItem> => ({
parser: LibraryItemP,
path: ["items", "random"],
Header.query = (): QueryIdentifier<Show> => ({
parser: Show,
path: ["api", "shows", "random"],
params: {
fields: ["firstEpisode"],
fields: ["firstEntry"],
},
});

View File

@@ -18,12 +18,10 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { Genre, type QueryPage, toQueryKey } from "@kyoo/models";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { RefreshControl, ScrollView } from "react-native";
import { Fetch } from "../fetch";
import { DefaultLayout } from "../layout";
import { Genre } from "~/models";
import { Fetch, prefetch } from "~/query";
import { GenreGrid } from "./genre";
import { Header } from "./header";
import { NewsList } from "./news";
@@ -31,9 +29,21 @@ import { Recommended } from "./recommended";
import { VerticalRecommended } from "./vertical";
import { WatchlistList } from "./watchlist";
export const HomePage: QueryPage<{}, Genre> = ({ randomItems }) => {
const queryClient = useQueryClient();
export async function loader() {
const randomItems = [...Object.values(Genre)];
await Promise.all([
prefetch(Header.query()),
prefetch(WatchlistList.query()),
prefetch(NewsList.query()),
...randomItems.filter((_, i) => i < 6).map((x) => prefetch(GenreGrid.query(x))),
prefetch(Recommended.query()),
prefetch(VerticalRecommended.query()),
]);
}
export const HomePage = () => {
const [refreshing, setRefreshing] = useState(false);
const randomItems = [...Object.values(Genre)];
return (
<ScrollView
@@ -41,34 +51,38 @@ export const HomePage: QueryPage<{}, Genre> = ({ randomItems }) => {
<RefreshControl
onRefresh={async () => {
setRefreshing(true);
await Promise.all(
HomePage.getFetchUrls!({}, randomItems).map((query) =>
queryClient.refetchQueries({
queryKey: toQueryKey(query),
type: "active",
exact: true,
}),
),
);
await loader();
setRefreshing(false);
}}
refreshing={refreshing}
/>
}
>
<Fetch query={Header.query()}>
{(x) => (
<Fetch
query={Header.query()}
Render={(x) => (
<Header
isLoading={x.isLoading as any}
isLoading={false}
name={x.name}
tagline={"tagline" in x ? x.tagline : null}
overview={x.overview}
tagline={x.kind !== "collection" && "tagline" in x ? x.tagline : null}
description={x.description}
thumbnail={x.thumbnail}
link={x.kind !== "collection" && !x.isLoading ? x.playHref : undefined}
link={x.kind !== "collection" ? x.playHref : null}
infoLink={x.href}
/>
)}
</Fetch>
Loader={() => (
<Header
isLoading={true}
name=""
tagline={null}
description={null}
thumbnail={null}
link={null}
infoLink="#"
/>
)}
/>
<WatchlistList />
<NewsList />
{randomItems
@@ -90,16 +104,3 @@ export const HomePage: QueryPage<{}, Genre> = ({ randomItems }) => {
</ScrollView>
);
};
HomePage.randomItems = [...Object.values(Genre)];
HomePage.getLayout = { Layout: DefaultLayout, props: { transparent: true } };
HomePage.getFetchUrls = (_, randomItems) => [
Header.query(),
WatchlistList.query(),
NewsList.query(),
...randomItems.filter((_, i) => i < 6).map((x) => GenreGrid.query(x)),
Recommended.query(),
VerticalRecommended.query(),
];

View File

@@ -18,12 +18,12 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type News, NewsP, type QueryIdentifier, getDisplayDate } from "@kyoo/models";
import { useTranslation } from "react-i18next";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid";
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode";
import { InfiniteFetch } from "../fetch-infinite";
import { EntryBox, entryDisplayNumber } from "~/components/entries";
import { ItemGrid } from "~/components/items";
import type { Entry } from "~/models";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { Header } from "./genre";
export const NewsList = () => {
@@ -40,17 +40,16 @@ export const NewsList = () => {
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")}
Render={({ item }) => {
if (item.kind === "episode") {
if (item.kind === "episode" || item.kind === "special") {
return (
<EpisodeBox
<EntryBox
slug={item.slug}
showSlug={item.show!.slug}
name={`${item.show!.name} ${episodeDisplayNumber(item)}`}
overview={item.name}
serieSlug={item.serie!.slug}
name={`${item.serie!.name} ${entryDisplayNumber(item)}`}
description={item.name}
thumbnail={item.thumbnail}
href={item.href}
watchedPercent={item.watchStatus?.watchedPercent || null}
watchedStatus={item.watchStatus?.status || null}
href={item.href ?? "#"}
watchedPercent={item.watchStatus?.percent || null}
// TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })}
@@ -59,31 +58,31 @@ export const NewsList = () => {
}
return (
<ItemGrid
href={item.href}
href={item.href ?? "#"}
slug={item.slug}
kind={"movie"}
name={item.name!}
subtitle={getDisplayDate(item)}
poster={item.poster}
subtitle={item.airDate ? new Date(item.airDate).getFullYear().toString() : null}
poster={item.kind === "movie" ? item.poster : null}
watchStatus={item.watchStatus?.status || null}
watchPercent={item.watchStatus?.watchedPercent || null}
watchPercent={item.watchStatus?.percent || null}
unseenEpisodesCount={null}
type={"movie"}
/>
);
}}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
Loader={({ index }) => (index % 2 ? <EntryBox.Loader /> : <ItemGrid.Loader />)}
/>
</>
);
};
NewsList.query = (): QueryIdentifier<News> => ({
parser: NewsP,
NewsList.query = (): QueryIdentifier<Entry> => ({
parser: Entry,
infinite: true,
path: ["news"],
path: ["api", "news"],
params: {
// Limit the initial numbers of items
limit: 10,
fields: ["show", "watchStatus"],
fields: ["serie", "watchStatus"],
},
});

View File

@@ -18,47 +18,42 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type Genre,
type KyooImage,
type LibraryItem,
LibraryItemP,
type QueryIdentifier,
type WatchStatusV,
getDisplayDate,
} from "@kyoo/models";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemGrid, ItemWatchStatus } from "~/components/items";
import { ItemContext } from "~/components/items/context-menus";
import type { Genre, KImage, Show, WatchStatusV } from "~/models";
import { getDisplayDate } from "~/utils";
import {
Chip,
H3,
IconFab,
Link,
P,
Poster,
PosterBackground,
Skeleton,
SubP,
focusReset,
imageBorderRadius,
tooltip,
ts,
} from "@kyoo/primitives";
import PlayArrow from "@material-symbols/svg-400/rounded/play_arrow-fill.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ScrollView, View } from "react-native";
import { type Theme, calc, percent, px, rem, useYoshiki } from "yoshiki/native";
import { ItemGrid, ItemWatchStatus } from "../browse/grid";
import { ItemContext } from "../../../../src/ui/info/components/context-menus";
import type { Layout } from "../fetch";
import { InfiniteFetch } from "../fetch-infinite";
} from "~/primitives";
import { InfiniteFetch, type Layout, type QueryIdentifier } from "~/query";
const imageBorderRadius = 6;
const focusReset = {
boxShadow: "unset",
outline: "none",
};
export const ItemDetails = ({
slug,
type,
kind,
name,
tagline,
subtitle,
overview,
description,
poster,
genres,
href,
@@ -68,13 +63,13 @@ export const ItemDetails = ({
...props
}: {
slug: string;
type: "movie" | "show" | "collection";
kind: "movie" | "serie" | "collection";
name: string;
tagline: string | null;
subtitle: string | null;
poster: KyooImage | null;
poster: KImage | null;
genres: Genre[] | null;
overview: string | null;
description: string | null;
href: string;
playHref: string | null;
watchStatus: WatchStatusV | null;
@@ -152,9 +147,9 @@ export const ItemDetails = ({
alignContent: "flex-start",
})}
>
{type !== "collection" && (
{kind !== "collection" && (
<ItemContext
type={type}
kind={kind}
slug={slug}
status={watchStatus}
isOpen={moreOpened}
@@ -165,7 +160,7 @@ export const ItemDetails = ({
{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
</View>
<ScrollView {...css({ pX: ts(1) })}>
<SubP {...css({ textAlign: "justify" })}>{overview ?? t("show.noOverview")}</SubP>
<SubP {...css({ textAlign: "justify" })}>{description ?? t("show.noOverview")}</SubP>
</ScrollView>
</View>
</Link>
@@ -231,9 +226,12 @@ ItemDetails.Loader = (props: object) => {
props,
)}
>
<Poster.Loader
<PosterBackground
src={null}
alt=""
quality="low"
layout={{ height: percent(100) }}
{...css({ borderTopRightRadius: 0, borderBottomRightRadius: 0 })}
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
>
<View
{...css({
@@ -248,7 +246,7 @@ ItemDetails.Loader = (props: object) => {
<Skeleton {...css({ width: percent(100) })} />
<Skeleton {...css({ height: rem(0.8) })} />
</View>
</Poster.Loader>
</PosterBackground>
<View {...css({ flexShrink: 1, flexGrow: 1 })}>
<View {...css({ flexGrow: 1, flexShrink: 1, pX: ts(1) })}>
<Skeleton {...css({ marginVertical: ts(2) })} />
@@ -295,19 +293,19 @@ export const Recommended = () => {
Render={({ item }) => (
<ItemDetails
slug={item.slug}
type={item.kind}
kind={item.kind}
name={item.name}
tagline={"tagline" in item ? item.tagline : null}
overview={item.overview}
tagline={item.kind !== "collection" && "tagline" in item ? item.tagline : null}
description={item.description}
poster={item.poster}
subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
genres={"genres" in item ? item.genres : null}
genres={item.kind !== "collection" && "genres" in item ? item.genres : null}
href={item.href}
playHref={item.kind !== "collection" ? item.playHref : null}
watchStatus={(item.kind !== "collection" && item.watchStatus?.status) || null}
unseenEpisodesCount={
item.kind === "show"
? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!)
item.kind === "serie"
? (item.availableCount - (item.watchStatus?.seenCount ?? 0))
: null
}
/>
@@ -318,13 +316,13 @@ export const Recommended = () => {
);
};
Recommended.query = (): QueryIdentifier<LibraryItem> => ({
parser: LibraryItemP,
Recommended.query = (): QueryIdentifier<Show> => ({
parser: Show,
infinite: true,
path: ["items"],
path: ["api", "shows"],
params: {
sortBy: "random",
sort: "random",
limit: 6,
fields: ["firstEpisode", "episodesCount", "watchStatus"],
fields: ["firstEntry", "watchStatus"],
},
});

View File

@@ -18,15 +18,13 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import { type LibraryItem, LibraryItemP, type QueryIdentifier } from "@kyoo/models";
import { H3 } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { itemMap } from "../browse";
import { ItemGrid } from "../browse/grid";
import { ItemList } from "../browse/list";
import { InfiniteFetch } from "../fetch-infinite";
import { ItemGrid, ItemList, itemMap } from "~/components/items";
import type { Show } from "~/models";
import { H3 } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
export const VerticalRecommended = () => {
const { t } = useTranslation();
@@ -48,13 +46,13 @@ export const VerticalRecommended = () => {
);
};
VerticalRecommended.query = (): QueryIdentifier<LibraryItem> => ({
parser: LibraryItemP,
VerticalRecommended.query = (): QueryIdentifier<Show> => ({
parser: Show,
infinite: true,
path: ["items"],
path: ["api", "shows"],
params: {
fields: ["episodesCount", "watchStatus"],
sortBy: "random",
fields: ["watchStatus"],
sort: "random",
limit: 3,
},
});

View File

@@ -18,20 +18,16 @@
* along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
*/
import {
type QueryIdentifier,
type Watchlist,
WatchlistP,
getDisplayDate,
useAccount,
} from "@kyoo/models";
import { Button, P, ts } from "@kyoo/primitives";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid } from "../browse/grid";
import { EpisodeBox, episodeDisplayNumber } from "../../../../src/ui/details/episode";
import { InfiniteFetch } from "../fetch-infinite";
import { EntryBox, entryDisplayNumber } from "~/components/entries";
import { ItemGrid } from "~/components/items";
import type { Show } from "~/models";
import { getDisplayDate } from "~/utils";
import { Button, P, ts } from "~/primitives";
import { useAccount } from "~/providers/account-context";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { Header } from "./genre";
export const WatchlistList = () => {
@@ -62,23 +58,22 @@ export const WatchlistList = () => {
query={WatchlistList.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
getItemType={(x, i) =>
(x?.kind === "show" && x.watchStatus?.nextEpisode) || (!x && i % 2) ? "episode" : "item"
(x?.kind === "serie" && x.nextEntry) || (!x && i % 2) ? "episode" : "item"
}
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")}
Render={({ item }) => {
const episode = item.kind === "show" ? item.watchStatus?.nextEpisode : null;
if (episode) {
const entry = item.kind === "serie" ? item.nextEntry : null;
if (entry) {
return (
<EpisodeBox
slug={episode.slug}
showSlug={item.slug}
name={`${item.name} ${episodeDisplayNumber(episode)}`}
overview={episode.name}
thumbnail={episode.thumbnail ?? item.thumbnail}
href={episode.href}
watchedPercent={item.watchStatus?.watchedPercent || null}
watchedStatus={item.watchStatus?.status || null}
<EntryBox
slug={entry.slug}
serieSlug={item.slug}
name={`${item.name} ${entryDisplayNumber(entry)}`}
description={entry.name}
thumbnail={entry.thumbnail ?? item.thumbnail}
href={entry.href ?? "#"}
watchedPercent={entry.watchStatus?.percent || null}
// TODO: Move this into the ItemList (using getItemSize)
// @ts-expect-error This is a web only property
{...css({ gridColumnEnd: "span 2" })}
@@ -89,31 +84,29 @@ export const WatchlistList = () => {
<ItemGrid
href={item.href}
slug={item.slug}
kind={item.kind}
name={item.name!}
subtitle={getDisplayDate(item)}
poster={item.poster}
watchStatus={item.watchStatus?.status || null}
watchPercent={item.watchStatus?.watchedPercent || null}
unseenEpisodesCount={
(item.kind === "show" && item.watchStatus?.unseenEpisodesCount) || null
}
type={item.kind}
watchPercent={item.kind === "movie" && item.watchStatus ? item.watchStatus.percent : null}
unseenEpisodesCount={null}
/>
);
}}
Loader={({ index }) => (index % 2 ? <EpisodeBox.Loader /> : <ItemGrid.Loader />)}
Loader={({ index }) => (index % 2 ? <EntryBox.Loader /> : <ItemGrid.Loader />)}
/>
</>
);
};
WatchlistList.query = (): QueryIdentifier<Watchlist> => ({
parser: WatchlistP,
WatchlistList.query = (): QueryIdentifier<Show> => ({
parser: Show,
infinite: true,
path: ["watchlist"],
path: ["api", "watchlist"],
params: {
// Limit the inital numbers of items
// Limit the initial numbers of items
limit: 10,
fields: ["watchStatus"],
fields: ["watchStatus", "nextEntry"],
},
});