Rework browse settings

This commit is contained in:
Zoe Roux 2025-06-20 19:05:41 +02:00
parent 9498dea3fd
commit 69c4e4e6d8
No known key found for this signature in database
12 changed files with 138 additions and 250 deletions

9
front/i18n-d.d.ts vendored
View File

@ -1,9 +0,0 @@
import "i18next";
import type en from "../public/translations/en.json";
declare module "i18next" {
interface CustomTypeOptions {
returnNull: false;
resources: { translation: typeof en };
}
}

View File

@ -1,2 +0,0 @@
export * from "./item-grid";
export * from "./item-list";

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 Refresh from "@material-symbols/svg-400/rounded/autorenew.svg"; import Refresh from "@material-symbols/svg-400/rounded/autorenew.svg";
// import Download from "@material-symbols/svg-400/rounded/download.svg"; // import Download from "@material-symbols/svg-400/rounded/download.svg";
import Info from "@material-symbols/svg-400/rounded/info.svg"; import Info from "@material-symbols/svg-400/rounded/info.svg";
@ -27,12 +7,15 @@ import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { WatchStatusV } from "~/models"; import type { Serie } from "~/models";
import { HR, IconButton, Menu, tooltip } from "~/primitives"; import { HR, IconButton, Menu, tooltip } from "~/primitives";
import { useAccount } from "~/providers/account-context"; import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query"; import { useMutation } from "~/query";
import { watchListIcon } from "./watchlist-info";
// import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads"; // import { useDownloader } from "../../packages/ui/src/downloadses/ui/src/downloads";
type WatchStatusV = NonNullable<Serie["watchStatus"]>["status"];
export const EpisodesContext = ({ export const EpisodesContext = ({
type = "episode", type = "episode",
slug, slug,

View File

@ -0,0 +1,23 @@
import type { ComponentProps } from "react";
import type { Show } from "~/models";
import { getDisplayDate } from "~/utils";
import { ItemGrid } from "./item-grid";
import { ItemList } from "./item-list";
export const itemMap = (
item: Show,
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
kind: item.kind,
slug: item.slug,
name: item.name,
subtitle: item.kind !== "collection" ? getDisplayDate(item) : null,
href: item.href,
poster: item.poster,
thumbnail: item.thumbnail,
watchStatus: item.kind !== "collection" ? (item.watchStatus?.status ?? null) : null,
watchPercent: item.kind === "movie" ? (item.watchStatus?.percent ?? null) : null,
// unseenEpisodesCount:
// item.kind === "serie" ? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!) : null,
});
export { ItemGrid, ItemList };

View File

@ -4,18 +4,21 @@ import BookmarkAdded from "@material-symbols/svg-400/rounded/bookmark_added-fill
import BookmarkRemove from "@material-symbols/svg-400/rounded/bookmark_remove.svg"; import BookmarkRemove from "@material-symbols/svg-400/rounded/bookmark_remove.svg";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { WatchStatusV } from "~/models"; import type { Serie } from "~/models";
import { IconButton, Menu, tooltip } from "~/primitives"; import { IconButton, Menu, tooltip } from "~/primitives";
import { useAccount } from "~/providers/account-context"; import { useAccount } from "~/providers/account-context";
import { useMutation } from "~/query"; import { useMutation } from "~/query";
export const watchListIcon = (status: WatchStatusV | null) => { type WatchStatus = NonNullable<Serie["watchStatus"]>["status"];
const WatchStatus = ["completed", "watching", "rewatching", "dropped", "planned"] as const;
export const watchListIcon = (status: WatchStatus | null) => {
switch (status) { switch (status) {
case null: case null:
return BookmarkAdd; return BookmarkAdd;
case WatchStatusV.Completed: case "completed":
return BookmarkAdded; return BookmarkAdded;
case WatchStatusV.Droped: case "dropped":
return BookmarkRemove; return BookmarkRemove;
default: default:
return Bookmark; return Bookmark;
@ -30,7 +33,7 @@ export const WatchListInfo = ({
}: { }: {
type: "movie" | "show" | "episode"; type: "movie" | "show" | "episode";
slug: string; slug: string;
status: WatchStatusV | null; status: WatchStatus | null;
color: ComponentProps<typeof IconButton>["color"]; color: ComponentProps<typeof IconButton>["color"];
}) => { }) => {
const account = useAccount(); const account = useAccount();
@ -38,7 +41,7 @@ export const WatchListInfo = ({
const mutation = useMutation({ const mutation = useMutation({
path: [type, slug, "watchStatus"], path: [type, slug, "watchStatus"],
compute: (newStatus: WatchStatusV | null) => ({ compute: (newStatus: WatchStatus | null) => ({
method: newStatus ? "POST" : "DELETE", method: newStatus ? "POST" : "DELETE",
params: newStatus ? { status: newStatus } : undefined, params: newStatus ? { status: newStatus } : undefined,
}), }),
@ -57,12 +60,12 @@ export const WatchListInfo = ({
return ( return (
<IconButton <IconButton
icon={BookmarkAdd} icon={BookmarkAdd}
onPress={() => mutation.mutate(WatchStatusV.Planned)} onPress={() => mutation.mutate("planned")}
{...tooltip(t("show.watchlistAdd"))} {...tooltip(t("show.watchlistAdd"))}
{...props} {...props}
/> />
); );
case WatchStatusV.Completed: case "completed":
return ( return (
<IconButton <IconButton
icon={BookmarkAdded} icon={BookmarkAdded}
@ -71,9 +74,10 @@ export const WatchListInfo = ({
{...props} {...props}
/> />
); );
case WatchStatusV.Planned: case "planned":
case WatchStatusV.Watching: case "watching":
case WatchStatusV.Droped: case "rewatching":
case "dropped":
return ( return (
<Menu <Menu
Trigger={IconButton} Trigger={IconButton}
@ -81,10 +85,10 @@ export const WatchListInfo = ({
{...tooltip(t("show.watchlistEdit"))} {...tooltip(t("show.watchlistEdit"))}
{...props} {...props}
> >
{Object.values(WatchStatusV).map((x) => ( {Object.values(WatchStatus).map((x) => (
<Menu.Item <Menu.Item
key={x} key={x}
label={t(`show.watchlistMark.${x.toLowerCase() as Lowercase<WatchStatusV>}`)} label={t(`show.watchlistMark.${x}`)}
onSelect={() => mutation.mutate(x)} onSelect={() => mutation.mutate(x)}
selected={x === status} selected={x === status}
/> />

View File

@ -8,3 +8,4 @@ export const Show = z.union([
Movie.and(z.object({ kind: z.literal("movie") })), Movie.and(z.object({ kind: z.literal("movie") })),
Collection.and(z.object({ kind: z.literal("collection") })), Collection.and(z.object({ kind: z.literal("collection") })),
]); ]);
export type Show = z.infer<typeof Show>;

View File

@ -1,79 +1,83 @@
import { HR, Icon, IconButton, Menu, P, PressableFeedback, tooltip, ts } from "@kyoo/primitives";
import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg"; import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg";
import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg"; import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg";
import Collection from "@material-symbols/svg-400/rounded/collections_bookmark.svg";
import FilterList from "@material-symbols/svg-400/rounded/filter_list.svg"; import FilterList from "@material-symbols/svg-400/rounded/filter_list.svg";
import GridView from "@material-symbols/svg-400/rounded/grid_view.svg"; import GridView from "@material-symbols/svg-400/rounded/grid_view.svg";
import Movie from "@material-symbols/svg-400/rounded/movie.svg";
import Sort from "@material-symbols/svg-400/rounded/sort.svg"; import Sort from "@material-symbols/svg-400/rounded/sort.svg";
import TV from "@material-symbols/svg-400/rounded/tv.svg";
import All from "@material-symbols/svg-400/rounded/view_headline.svg";
import ViewList from "@material-symbols/svg-400/rounded/view_list.svg"; import ViewList from "@material-symbols/svg-400/rounded/view_list.svg";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { type PressableProps, View } from "react-native"; import { type PressableProps, View } from "react-native";
import { useYoshiki } from "yoshiki/native"; import { useYoshiki } from "yoshiki/native";
import { Layout, type MediaType, MediaTypeAll, SearchSort, SortOrd } from "./types"; import { HR, Icon, IconButton, Menu, P, PressableFeedback, tooltip, ts } from "~/primitives";
import { type SortBy, type SortOrd, availableSorts } from "./types";
const SortTrigger = forwardRef<View, PressableProps & { sortKey: string }>(function SortTrigger( const SortTrigger = ({ sortBy, ...props }: { sortBy: SortBy } & PressableProps) => {
{ sortKey, ...props },
ref,
) {
const { css } = useYoshiki(); const { css } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<PressableFeedback <PressableFeedback
ref={ref}
{...css({ flexDirection: "row", alignItems: "center" }, props as any)} {...css({ flexDirection: "row", alignItems: "center" }, props as any)}
{...tooltip(t("browse.sortby-tt"))} {...tooltip(t("browse.sortby-tt"))}
> >
<Icon icon={Sort} {...css({ paddingX: ts(0.5) })} /> <Icon icon={Sort} {...css({ paddingX: ts(0.5) })} />
<P>{t(`browse.sortkey.${sortKey}` as any)}</P> <P>{t(`browse.sortkey.${sortBy}`)}</P>
</PressableFeedback> </PressableFeedback>
); );
}); };
const MediaTypeTrigger = forwardRef<View, PressableProps & { mediaType: MediaType }>( const MediaTypeIcons = {
function MediaTypeTrigger({ mediaType, ...props }, ref) { all: All,
const { css } = useYoshiki(); movie: Movie,
const { t } = useTranslation(); serie: TV,
const labelKey = collection: Collection,
mediaType !== MediaTypeAll ? `browse.mediatypekey.${mediaType.key}` : "browse.mediatypelabel"; };
const icon = mediaType !== MediaTypeAll ? (mediaType?.icon ?? FilterList) : FilterList;
return ( const MediaTypeTrigger = ({
<PressableFeedback mediaType,
ref={ref} ...props
{...css({ flexDirection: "row", alignItems: "center" }, props as any)} }: PressableProps & { mediaType: keyof typeof MediaTypeIcons }) => {
{...tooltip(t("browse.mediatype-tt"))} const { css } = useYoshiki();
> const { t } = useTranslation();
<Icon icon={icon} {...css({ paddingX: ts(0.5) })} />
<P>{t(labelKey as any)}</P> return (
</PressableFeedback> <PressableFeedback
); {...css({ flexDirection: "row", alignItems: "center" }, props as any)}
}, {...tooltip(t("browse.mediatype-tt"))}
); >
<Icon icon={MediaTypeIcons[mediaType] ?? FilterList} {...css({ paddingX: ts(0.5) })} />
<P>{t(mediaType !== "all" ? `browse.mediatypekey.${mediaType}` : "browse.mediatypelabel")}</P>
</PressableFeedback>
);
};
export const BrowseSettings = ({ export const BrowseSettings = ({
availableSorts, sortBy,
sortKey,
sortOrd, sortOrd,
setSort, setSort,
availableMediaTypes, filter,
mediaType, setFilter,
setMediaType,
layout, layout,
setLayout, setLayout,
}: { }: {
availableSorts: string[]; sortBy: SortBy;
sortKey: string;
sortOrd: SortOrd; sortOrd: SortOrd;
setSort: (sort: string, ord: SortOrd) => void; setSort: (sort: SortBy, ord: SortOrd) => void;
availableMediaTypes: MediaType[]; filter: string;
mediaType: MediaType; setFilter: (filter: string) => void;
setMediaType: (mediaType: MediaType) => void; layout: "grid" | "list";
layout: Layout; setLayout: (layout: "grid" | "list") => void;
setLayout: (layout: Layout) => void;
}) => { }) => {
const { css, theme } = useYoshiki(); const { css, theme } = useYoshiki();
const { t } = useTranslation(); const { t } = useTranslation();
// TODO: have a proper filter frontend
const mediaType = /kind eq (\w+)/.exec(filter)?.groups?.[0] ?? "all";
const setMediaType = (kind: string) => setFilter(kind !== "all " ? `kind eq ${kind}` : "");
return ( return (
<View <View
{...css({ {...css({
@ -84,49 +88,41 @@ export const BrowseSettings = ({
})} })}
> >
<View {...css({ flexDirection: "row" })}> <View {...css({ flexDirection: "row" })}>
<Menu Trigger={SortTrigger} sortKey={sortKey}> <Menu Trigger={SortTrigger} sortBy={sortBy}>
{availableSorts.map((x) => ( {availableSorts.map((x) => (
<Menu.Item <Menu.Item
key={x} key={x}
label={t(`browse.sortkey.${x}` as any)} label={t(`browse.sortkey.${x}`)}
selected={sortKey === x} selected={sortBy === x}
icon={ icon={sortOrd === "asc" ? ArrowUpward : ArrowDownward}
x !== SearchSort.Relevance onSelect={() => setSort(x, sortBy === x && sortOrd === "asc" ? "desc" : "asc")}
? sortOrd === SortOrd.Asc
? ArrowUpward
: ArrowDownward
: undefined
}
onSelect={() =>
setSort(x, sortKey === x && sortOrd === SortOrd.Asc ? SortOrd.Desc : SortOrd.Asc)
}
/> />
))} ))}
</Menu> </Menu>
<HR orientation="vertical" /> <HR orientation="vertical" />
<IconButton <IconButton
icon={GridView} icon={GridView}
onPress={() => setLayout(Layout.Grid)} onPress={() => setLayout("grid")}
color={layout === Layout.Grid ? theme.accent : undefined} color={layout === "grid" ? theme.accent : undefined}
{...tooltip(t("browse.switchToGrid"))} {...tooltip(t("browse.switchToGrid"))}
{...css({ padding: ts(0.5), marginY: "auto" })} {...css({ padding: ts(0.5), marginY: "auto" })}
/> />
<IconButton <IconButton
icon={ViewList} icon={ViewList}
onPress={() => setLayout(Layout.List)} onPress={() => setLayout("list")}
color={layout === Layout.List ? theme.accent : undefined} color={layout === "list" ? theme.accent : undefined}
{...tooltip(t("browse.switchToList"))} {...tooltip(t("browse.switchToList"))}
{...css({ padding: ts(0.5), marginY: "auto" })} {...css({ padding: ts(0.5), marginY: "auto" })}
/> />
</View> </View>
<View {...css({ flexGrow: 1, flexDirection: "row", alignItems: "center" })}> <View {...css({ flexGrow: 1, flexDirection: "row", alignItems: "center" })}>
<Menu Trigger={MediaTypeTrigger} mediaType={mediaType}> <Menu Trigger={MediaTypeTrigger} mediaType={mediaType as keyof typeof MediaTypeIcons}>
{availableMediaTypes.map((x) => ( {Object.keys(MediaTypeIcons).map((x) => (
<Menu.Item <Menu.Item
key={x.key} key={x}
label={t(`browse.mediatypekey.${x.key}` as any)} label={t(`browse.mediatypekey.${x}`)}
selected={mediaType === x} selected={mediaType === x}
icon={x.icon} icon={MediaTypeIcons[x as keyof typeof MediaTypeIcons]}
onSelect={() => setMediaType(x)} onSelect={() => setMediaType(x)}
/> />
))} ))}

View File

@ -1,90 +1,34 @@
import { type ComponentProps, useState } from "react"; import { useState } from "react";
import { ItemGrid } from "~/components"; import { ItemGrid, itemMap } from "~/components/items";
import { type LibraryItem, LibraryItemP, getDisplayDate } from "~/models"; import { ItemList } from "~/components/items";
import { InfiniteFetch, type QueryIdentifier, type QueryPage } from "~/query"; import { Show } from "~/models";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { useQueryState } from "~/utils"; import { useQueryState } from "~/utils";
import { DefaultLayout } from "../../../packages/ui/src/layout";
import { ItemList } from "../../components/item-list";
import { BrowseSettings } from "./header"; import { BrowseSettings } from "./header";
import { import type { SortBy, SortOrd } from "./types";
Layout,
type MediaType,
MediaTypeAll,
MediaTypeKey,
MediaTypes,
SortBy,
SortOrd,
} from "./types";
export const itemMap = ( export const BrowsePage = () => {
item: LibraryItem, const [filter, setFilter] = useQueryState("filter", "");
): ComponentProps<typeof ItemGrid> & ComponentProps<typeof ItemList> => ({
slug: item.slug,
name: item.name,
subtitle: item.kind !== "collection" ? getDisplayDate(item) : null,
href: item.href,
poster: item.poster,
thumbnail: item.thumbnail,
watchStatus: item.kind !== "collection" ? (item.watchStatus?.status ?? null) : null,
type: item.kind,
watchPercent: item.kind !== "collection" ? (item.watchStatus?.watchedPercent ?? null) : null,
unseenEpisodesCount:
item.kind === "show" ? (item.watchStatus?.unseenEpisodesCount ?? item.episodesCount!) : null,
});
export const createFilterString = (mediaType: MediaType): string | undefined => {
return mediaType !== MediaTypeAll ? `kind eq ${mediaType.key}` : undefined;
};
const query = (
mediaType: MediaType,
sortKey?: SortBy,
sortOrd?: SortOrd,
): QueryIdentifier<LibraryItem> => {
return {
parser: LibraryItemP,
path: ["items"],
infinite: true,
params: {
sortBy: sortKey ? `${sortKey}:${sortOrd ?? "asc"}` : "name:asc",
filter: createFilterString(mediaType),
fields: ["watchStatus", "episodesCount"],
},
};
};
export const getMediaTypeFromParam = (mediaTypeParam?: string): MediaType => {
const mediaTypeKey = (mediaTypeParam as MediaTypeKey) || MediaTypeKey.All;
return MediaTypes.find((t) => t.key === mediaTypeKey) ?? MediaTypeAll;
};
export const BrowsePage: QueryPage = () => {
const [sort, setSort] = useQueryState("sortBy", ""); const [sort, setSort] = useQueryState("sortBy", "");
const [mediaTypeParam, setMediaTypeParam] = useQueryState("mediaType", ""); const sortBy = (sort?.split(":")[0] as SortBy) || "name";
const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name; const sortOrd = (sort?.split(":")[1] as SortOrd) || "asc";
const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc;
const [layout, setLayout] = useState(Layout.Grid);
const mediaType = getMediaTypeFromParam(mediaTypeParam); const [layout, setLayout] = useState<"grid" | "list">("grid");
const LayoutComponent = layout === Layout.Grid ? ItemGrid : ItemList; const LayoutComponent = layout === "grid" ? ItemGrid : ItemList;
return ( return (
<InfiniteFetch <InfiniteFetch
query={query(mediaType, sortKey, sortOrd)} query={BrowsePage.query(filter, sortBy, sortOrd)}
layout={LayoutComponent.layout} layout={LayoutComponent.layout}
Header={ Header={
<BrowseSettings <BrowseSettings
availableSorts={Object.values(SortBy)} sortBy={sortBy}
sortKey={sortKey}
sortOrd={sortOrd} sortOrd={sortOrd}
setSort={(key, ord) => { setSort={(key, ord) => {
setSort(`${key}:${ord}`); setSort(`${key}:${ord}`);
}} }}
mediaType={mediaType} filter={filter}
availableMediaTypes={MediaTypes} setFilter={setFilter}
setMediaType={(mediaType) => {
setMediaTypeParam(mediaType.key);
}}
layout={layout} layout={layout}
setLayout={setLayout} setLayout={setLayout}
/> />
@ -95,9 +39,18 @@ export const BrowsePage: QueryPage = () => {
); );
}; };
BrowsePage.getLayout = DefaultLayout; BrowsePage.query = (
filter?: string,
BrowsePage.getFetchUrls = ({ mediaType, sortBy }) => { sortKey?: SortBy,
const mediaTypeObj = getMediaTypeFromParam(mediaType); sortOrd?: SortOrd,
return [query(mediaTypeObj, sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd)]; ): QueryIdentifier<Show> => {
return {
parser: Show,
path: ["shows"],
infinite: true,
params: {
sort: sortKey ? `${sortKey}:${sortOrd ?? "asc"}` : "name:asc",
filter,
},
};
}; };

View File

@ -1,64 +1,3 @@
import Collection from "@material-symbols/svg-400/rounded/collections_bookmark.svg"; export const availableSorts = ["name", "startAir", "endAir", "createdAt", "rating"] as const;
import Movie from "@material-symbols/svg-400/rounded/movie.svg"; export type SortBy = (typeof availableSorts)[number];
import TV from "@material-symbols/svg-400/rounded/tv.svg"; export type SortOrd = "asc" | "desc";
import All from "@material-symbols/svg-400/rounded/view_headline.svg";
import type { ComponentType } from "react";
import type { SvgProps } from "react-native-svg";
export enum SortBy {
Name = "name",
StartAir = "startAir",
EndAir = "endAir",
AddedDate = "addedDate",
Ratings = "rating",
}
export enum SearchSort {
Relevance = "relevance",
AirDate = "airDate",
AddedDate = "addedDate",
Ratings = "rating",
}
export enum SortOrd {
Asc = "asc",
Desc = "desc",
}
export enum Layout {
Grid,
List,
}
export enum MediaTypeKey {
All = "all",
Movie = "movie",
Show = "show",
Collection = "collection",
}
export interface MediaType {
key: MediaTypeKey;
icon: ComponentType<SvgProps>;
}
export const MediaTypeAll: MediaType = {
key: MediaTypeKey.All,
icon: All,
};
export const MediaTypes: MediaType[] = [
MediaTypeAll,
{
key: MediaTypeKey.Movie,
icon: Movie,
},
{
key: MediaTypeKey.Show,
icon: TV,
},
{
key: MediaTypeKey.Collection,
icon: Collection,
},
];