Rework genres, news & recommended of home page

This commit is contained in:
Zoe Roux 2026-01-01 16:41:59 +01:00
parent 41e00532d8
commit 8f71099e7e
No known key found for this signature in database
5 changed files with 119 additions and 149 deletions

View File

@ -1,5 +1,7 @@
import { isLetter } from "char-info";
import {
anyStringOf,
charWhere,
digit,
float,
int,
@ -43,7 +45,17 @@ function t<T>(parser: Parjser<T>): Parjser<T> {
return parser.pipe(thenq(string(" ").pipe(many())));
}
const enumP = t(letter().pipe(many1(), stringify()).expects("an enum value"));
const enumP = t(
charWhere(
(x) =>
isLetter(x) ||
x === "-" || {
reason: "Expected a letter or a `-`",
},
)
.pipe(many1(), stringify())
.expects("an enum value"),
);
const property = t(letter().pipe(many1(), stringify())).expects("a property");

View File

@ -1,31 +1,12 @@
/*
* 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 } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid, itemMap } from "~/components/items";
import type { Genre, Show } from "~/models";
import { type Genre, Show } from "~/models";
import { H3, ts } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { EmptyView } from "../errors";
export const Header = ({ title }: { title: string }) => {
const { css } = useYoshiki();
@ -56,7 +37,11 @@ export const GenreGrid = ({ genre }: { genre: Genre }) => {
query={GenreGrid.query(genre)}
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
placeholderCount={2}
empty={displayEmpty.current ? t("home.none") : undefined}
Empty={
displayEmpty.current ? (
<EmptyView message={t("home.none")} />
) : undefined
}
Render={({ item }) => <ItemGrid {...itemMap(item)} />}
Loader={ItemGrid.Loader}
/>
@ -69,7 +54,6 @@ GenreGrid.query = (genre: Genre): QueryIdentifier<Show> => ({
infinite: true,
path: ["api", "shows"],
params: {
fields: ["watchStatus"],
filter: `genres has ${genre}`,
sort: "random",
// Limit the initial numbers of items

View File

@ -1,34 +1,13 @@
/*
* 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 { useTranslation } from "react-i18next";
import { useYoshiki } from "yoshiki/native";
import { EntryBox, entryDisplayNumber } from "~/components/entries";
import { ItemGrid } from "~/components/items";
import type { Entry } from "~/models";
import { Entry } from "~/models";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { EmptyView } from "../errors";
import { Header } from "./genre";
export const NewsList = () => {
const { t } = useTranslation();
const { css } = useYoshiki();
return (
<>
@ -36,41 +15,46 @@ export const NewsList = () => {
<InfiniteFetch
query={NewsList.query()}
layout={{ ...ItemGrid.layout, layout: "horizontal" }}
getItemType={(x, i) => (x?.kind === "movie" || (!x && i % 2) ? "movie" : "episode")}
getItemSize={(kind) => (kind === "episode" ? 2 : 1)}
empty={t("home.none")}
// getItemType={(x, i) =>
// x?.kind === "movie" || (!x && i % 2) ? "movie" : "episode"
// }
// getItemSizeMult={(_, __, kind) => (kind === "episode" ? 2 : 1)}
Empty={<EmptyView message={t("home.none")} />}
Render={({ item }) => {
if (item.kind === "episode" || item.kind === "special") {
return (
<EntryBox
slug={item.slug}
serieSlug={item.serie!.slug}
name={`${item.serie!.name} ${entryDisplayNumber(item)}`}
description={item.name}
thumbnail={item.thumbnail}
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" })}
/>
);
}
// if (item.kind === "episode" || item.kind === "special") {
return (
<ItemGrid
href={item.href ?? "#"}
<EntryBox
slug={item.slug}
kind={"movie"}
name={item.name!}
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?.percent || null}
unseenEpisodesCount={null}
serieSlug={item.slug}
name={`${item.name} ${entryDisplayNumber(item)}`}
description={item.name}
thumbnail={item.thumbnail}
href={item.href ?? "#"}
watchedPercent={item.watchStatus?.percent || null}
/>
);
// }
// return (
// <ItemGrid
// href={item.href ?? "#"}
// slug={item.slug}
// kind={"movie"}
// name={item.name!}
// 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?.percent || null}
// unseenEpisodesCount={null}
// />
// );
}}
Loader={({ index }) => (index % 2 ? <EntryBox.Loader /> : <ItemGrid.Loader />)}
Loader={({ index }) =>
index % 2 ? <EntryBox.Loader /> : <ItemGrid.Loader />
}
/>
</>
);
@ -81,8 +65,6 @@ NewsList.query = (): QueryIdentifier<Entry> => ({
infinite: true,
path: ["api", "news"],
params: {
// Limit the initial numbers of items
limit: 10,
fields: ["serie", "watchStatus"],
},
});

View File

@ -1,34 +1,15 @@
/*
* 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 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 { ItemGrid } from "~/components/items";
import { ItemContext } from "~/components/items/context-menus";
import type { Genre, KImage, Show, WatchStatusV } from "~/models";
import { type Genre, type KImage, Show, type WatchStatusV } from "~/models";
import { getDisplayDate } from "~/utils";
import {
Chip,
focusReset,
H3,
IconFab,
Link,
@ -40,12 +21,7 @@ import {
ts,
} from "~/primitives";
import { InfiniteFetch, type Layout, type QueryIdentifier } from "~/query";
const imageBorderRadius = 6;
const focusReset = {
boxShadow: "unset",
outline: "none",
};
import { ItemWatchStatus } from "~/components/items/item-helpers";
export const ItemDetails = ({
slug,
@ -99,7 +75,7 @@ export const ItemDetails = ({
bottom: 0,
flexDirection: "row",
bg: (theme) => theme.variant.background,
borderRadius: calc(px(imageBorderRadius), "+", ts(0.25)),
borderRadius: px(12),
overflow: "hidden",
borderColor: (theme) => theme.background,
borderWidth: ts(0.25),
@ -132,13 +108,28 @@ export const ItemDetails = ({
p: ts(1),
})}
>
<P {...css([{ m: 0, color: (theme: Theme) => theme.colors.white }, "title"])}>{name}</P>
{subtitle && <SubP {...css({ m: 0 })}>{subtitle}</SubP>}
<P
{...css([
{ m: 0, color: (theme: Theme) => theme.colors.white },
"title",
])}
>
{name}
</P>
{subtitle && <SubP {...(css({ m: 0 }) as any)}>{subtitle}</SubP>}
</View>
<ItemWatchStatus watchStatus={watchStatus} unseenEpisodesCount={unseenEpisodesCount} />
<ItemWatchStatus
watchStatus={watchStatus}
unseenEpisodesCount={unseenEpisodesCount}
/>
</PosterBackground>
<View
{...css({ flexShrink: 1, flexGrow: 1, justifyContent: "flex-end", marginBottom: px(50) })}
{...css({
flexShrink: 1,
flexGrow: 1,
justifyContent: "flex-end",
marginBottom: px(50),
})}
>
<View
{...css({
@ -154,13 +145,14 @@ export const ItemDetails = ({
status={watchStatus}
isOpen={moreOpened}
setOpen={(v) => setMoreOpened(v)}
force
/>
)}
{tagline && <P {...css({ p: ts(1) })}>{tagline}</P>}
</View>
<ScrollView {...css({ pX: ts(1) })}>
<SubP {...css({ textAlign: "justify" })}>{description ?? t("show.noOverview")}</SubP>
<SubP {...css({ textAlign: "justify" })}>
{description ?? t("show.noOverview")}
</SubP>
</ScrollView>
</View>
</Link>
@ -174,10 +166,10 @@ export const ItemDetails = ({
right: ts(0.25),
borderWidth: ts(0.25),
borderColor: "transparent",
borderBottomEndRadius: px(imageBorderRadius),
borderBottomEndRadius: px(6),
overflow: "hidden",
// Calculate the size of the poster
left: calc(ItemDetails.layout.size, "*", 2 / 3),
left: calc(px(ItemDetails.layout.size), "*", 2 / 3),
bg: (theme) => theme.themeOverlay,
flexDirection: "row",
pX: 4,
@ -186,9 +178,18 @@ export const ItemDetails = ({
})}
>
{genres && (
<ScrollView horizontal contentContainerStyle={{ alignItems: "center" }}>
<ScrollView
horizontal
contentContainerStyle={{ alignItems: "center" }}
>
{genres.map((x, i) => (
<Chip key={x ?? i} label={t(`genres.${x}`)} size="small" {...css({ mX: ts(0.5) })} />
<Chip
key={x ?? i}
label={t(`genres.${x}`)}
href={"#"}
size="small"
{...css({ mX: ts(0.5) })}
/>
))}
</ScrollView>
)}
@ -199,7 +200,9 @@ export const ItemDetails = ({
as={Link}
href={playHref}
{...tooltip(t("show.play"))}
{...css({ fover: { self: { transform: "scale(1.2)" as any, mX: ts(0.5) } } })}
{...css({
fover: { self: { transform: "scale(1.2)" as any, mX: ts(0.5) } },
})}
/>
)}
</View>
@ -217,7 +220,7 @@ ItemDetails.Loader = (props: object) => {
height: ItemDetails.layout.size,
flexDirection: "row",
bg: (theme) => theme.variant.background,
borderRadius: calc(px(imageBorderRadius), "+", ts(0.25)),
borderRadius: px(12),
overflow: "hidden",
borderColor: (theme) => theme.background,
borderWidth: ts(0.25),
@ -281,31 +284,42 @@ export const Recommended = () => {
const { css } = useYoshiki();
return (
<View {...css({ marginX: ItemGrid.layout.gap, marginTop: ItemGrid.layout.gap })}>
<View
{...css({ marginX: ItemGrid.layout.gap, marginTop: ItemGrid.layout.gap })}
>
<H3 {...css({ pX: ts(0.5) })}>{t("home.recommended")}</H3>
<InfiniteFetch
query={Recommended.query()}
layout={ItemDetails.layout}
placeholderCount={6}
fetchMore={false}
nested
contentContainerStyle={{ padding: 0, paddingHorizontal: 0 }}
Render={({ item }) => (
<ItemDetails
slug={item.slug}
kind={item.kind}
name={item.name}
tagline={item.kind !== "collection" && "tagline" in item ? item.tagline : null}
tagline={
item.kind !== "collection" && "tagline" in item
? item.tagline
: null
}
description={item.description}
poster={item.poster}
subtitle={item.kind !== "collection" ? getDisplayDate(item) : null}
genres={item.kind !== "collection" && "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}
watchStatus={
(item.kind !== "collection" && item.watchStatus?.status) || null
}
unseenEpisodesCount={
item.kind === "serie"
? (item.availableCount - (item.watchStatus?.seenCount ?? 0))
? item.availableCount - (item.watchStatus?.seenCount ?? 0)
: null
}
/>
@ -323,6 +337,6 @@ Recommended.query = (): QueryIdentifier<Show> => ({
params: {
sort: "random",
limit: 6,
fields: ["firstEntry", "watchStatus"],
with: ["firstEntry"],
},
});

View File

@ -1,28 +1,8 @@
/*
* 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 { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { ItemGrid, ItemList, itemMap } from "~/components/items";
import type { Show } from "~/models";
import { Show } from "~/models";
import { H3 } from "~/primitives";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
@ -38,7 +18,6 @@ export const VerticalRecommended = () => {
placeholderCount={3}
layout={{ ...ItemList.layout, layout: "vertical" }}
fetchMore={false}
nested
Render={({ item }) => <ItemList {...itemMap(item)} />}
Loader={() => <ItemList.Loader />}
/>
@ -51,7 +30,6 @@ VerticalRecommended.query = (): QueryIdentifier<Show> => ({
infinite: true,
path: ["api", "shows"],
params: {
fields: ["watchStatus"],
sort: "random",
limit: 3,
},