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 Download from "@material-symbols/svg-400/rounded/download.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 { Platform } from "react-native";
import { useYoshiki } from "yoshiki/native";
import { WatchStatusV } from "~/models";
import type { Serie } 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

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

View File

@ -8,3 +8,4 @@ export const Show = z.union([
Movie.and(z.object({ kind: z.literal("movie") })),
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 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 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 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 { forwardRef } from "react";
import { useTranslation } from "react-i18next";
import { type PressableProps, View } from "react-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(
{ sortKey, ...props },
ref,
) {
const SortTrigger = ({ sortBy, ...props }: { sortBy: SortBy } & PressableProps) => {
const { css } = useYoshiki();
const { t } = useTranslation();
return (
<PressableFeedback
ref={ref}
{...css({ flexDirection: "row", alignItems: "center" }, props as any)}
{...tooltip(t("browse.sortby-tt"))}
>
<Icon icon={Sort} {...css({ paddingX: ts(0.5) })} />
<P>{t(`browse.sortkey.${sortKey}` as any)}</P>
<P>{t(`browse.sortkey.${sortBy}`)}</P>
</PressableFeedback>
);
});
};
const MediaTypeTrigger = forwardRef<View, PressableProps & { mediaType: MediaType }>(
function MediaTypeTrigger({ mediaType, ...props }, ref) {
const MediaTypeIcons = {
all: All,
movie: Movie,
serie: TV,
collection: Collection,
};
const MediaTypeTrigger = ({
mediaType,
...props
}: PressableProps & { mediaType: keyof typeof MediaTypeIcons }) => {
const { css } = useYoshiki();
const { t } = useTranslation();
const labelKey =
mediaType !== MediaTypeAll ? `browse.mediatypekey.${mediaType.key}` : "browse.mediatypelabel";
const icon = mediaType !== MediaTypeAll ? (mediaType?.icon ?? FilterList) : FilterList;
return (
<PressableFeedback
ref={ref}
{...css({ flexDirection: "row", alignItems: "center" }, props as any)}
{...tooltip(t("browse.mediatype-tt"))}
>
<Icon icon={icon} {...css({ paddingX: ts(0.5) })} />
<P>{t(labelKey as any)}</P>
<Icon icon={MediaTypeIcons[mediaType] ?? FilterList} {...css({ paddingX: ts(0.5) })} />
<P>{t(mediaType !== "all" ? `browse.mediatypekey.${mediaType}` : "browse.mediatypelabel")}</P>
</PressableFeedback>
);
},
);
};
export const BrowseSettings = ({
availableSorts,
sortKey,
sortBy,
sortOrd,
setSort,
availableMediaTypes,
mediaType,
setMediaType,
filter,
setFilter,
layout,
setLayout,
}: {
availableSorts: string[];
sortKey: string;
sortBy: SortBy;
sortOrd: SortOrd;
setSort: (sort: string, ord: SortOrd) => void;
availableMediaTypes: MediaType[];
mediaType: MediaType;
setMediaType: (mediaType: MediaType) => void;
layout: Layout;
setLayout: (layout: Layout) => void;
setSort: (sort: SortBy, ord: SortOrd) => void;
filter: string;
setFilter: (filter: string) => void;
layout: "grid" | "list";
setLayout: (layout: "grid" | "list") => void;
}) => {
const { css, theme } = useYoshiki();
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 (
<View
{...css({
@ -84,49 +88,41 @@ export const BrowseSettings = ({
})}
>
<View {...css({ flexDirection: "row" })}>
<Menu Trigger={SortTrigger} sortKey={sortKey}>
<Menu Trigger={SortTrigger} sortBy={sortBy}>
{availableSorts.map((x) => (
<Menu.Item
key={x}
label={t(`browse.sortkey.${x}` as any)}
selected={sortKey === x}
icon={
x !== SearchSort.Relevance
? sortOrd === SortOrd.Asc
? ArrowUpward
: ArrowDownward
: undefined
}
onSelect={() =>
setSort(x, sortKey === x && sortOrd === SortOrd.Asc ? SortOrd.Desc : SortOrd.Asc)
}
label={t(`browse.sortkey.${x}`)}
selected={sortBy === x}
icon={sortOrd === "asc" ? ArrowUpward : ArrowDownward}
onSelect={() => setSort(x, sortBy === x && sortOrd === "asc" ? "desc" : "asc")}
/>
))}
</Menu>
<HR orientation="vertical" />
<IconButton
icon={GridView}
onPress={() => setLayout(Layout.Grid)}
color={layout === Layout.Grid ? theme.accent : undefined}
onPress={() => setLayout("grid")}
color={layout === "grid" ? theme.accent : undefined}
{...tooltip(t("browse.switchToGrid"))}
{...css({ padding: ts(0.5), marginY: "auto" })}
/>
<IconButton
icon={ViewList}
onPress={() => setLayout(Layout.List)}
color={layout === Layout.List ? theme.accent : undefined}
onPress={() => setLayout("list")}
color={layout === "list" ? theme.accent : undefined}
{...tooltip(t("browse.switchToList"))}
{...css({ padding: ts(0.5), marginY: "auto" })}
/>
</View>
<View {...css({ flexGrow: 1, flexDirection: "row", alignItems: "center" })}>
<Menu Trigger={MediaTypeTrigger} mediaType={mediaType}>
{availableMediaTypes.map((x) => (
<Menu Trigger={MediaTypeTrigger} mediaType={mediaType as keyof typeof MediaTypeIcons}>
{Object.keys(MediaTypeIcons).map((x) => (
<Menu.Item
key={x.key}
label={t(`browse.mediatypekey.${x.key}` as any)}
key={x}
label={t(`browse.mediatypekey.${x}`)}
selected={mediaType === x}
icon={x.icon}
icon={MediaTypeIcons[x as keyof typeof MediaTypeIcons]}
onSelect={() => setMediaType(x)}
/>
))}

View File

@ -1,90 +1,34 @@
import { type ComponentProps, useState } from "react";
import { ItemGrid } from "~/components";
import { type LibraryItem, LibraryItemP, getDisplayDate } from "~/models";
import { InfiniteFetch, type QueryIdentifier, type QueryPage } from "~/query";
import { useState } from "react";
import { ItemGrid, itemMap } from "~/components/items";
import { ItemList } from "~/components/items";
import { Show } from "~/models";
import { InfiniteFetch, type QueryIdentifier } from "~/query";
import { useQueryState } from "~/utils";
import { DefaultLayout } from "../../../packages/ui/src/layout";
import { ItemList } from "../../components/item-list";
import { BrowseSettings } from "./header";
import {
Layout,
type MediaType,
MediaTypeAll,
MediaTypeKey,
MediaTypes,
SortBy,
SortOrd,
} from "./types";
import type { SortBy, SortOrd } from "./types";
export const itemMap = (
item: LibraryItem,
): 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 = () => {
export const BrowsePage = () => {
const [filter, setFilter] = useQueryState("filter", "");
const [sort, setSort] = useQueryState("sortBy", "");
const [mediaTypeParam, setMediaTypeParam] = useQueryState("mediaType", "");
const sortKey = (sort?.split(":")[0] as SortBy) || SortBy.Name;
const sortOrd = (sort?.split(":")[1] as SortOrd) || SortOrd.Asc;
const [layout, setLayout] = useState(Layout.Grid);
const sortBy = (sort?.split(":")[0] as SortBy) || "name";
const sortOrd = (sort?.split(":")[1] as SortOrd) || "asc";
const mediaType = getMediaTypeFromParam(mediaTypeParam);
const LayoutComponent = layout === Layout.Grid ? ItemGrid : ItemList;
const [layout, setLayout] = useState<"grid" | "list">("grid");
const LayoutComponent = layout === "grid" ? ItemGrid : ItemList;
return (
<InfiniteFetch
query={query(mediaType, sortKey, sortOrd)}
query={BrowsePage.query(filter, sortBy, sortOrd)}
layout={LayoutComponent.layout}
Header={
<BrowseSettings
availableSorts={Object.values(SortBy)}
sortKey={sortKey}
sortBy={sortBy}
sortOrd={sortOrd}
setSort={(key, ord) => {
setSort(`${key}:${ord}`);
}}
mediaType={mediaType}
availableMediaTypes={MediaTypes}
setMediaType={(mediaType) => {
setMediaTypeParam(mediaType.key);
}}
filter={filter}
setFilter={setFilter}
layout={layout}
setLayout={setLayout}
/>
@ -95,9 +39,18 @@ export const BrowsePage: QueryPage = () => {
);
};
BrowsePage.getLayout = DefaultLayout;
BrowsePage.getFetchUrls = ({ mediaType, sortBy }) => {
const mediaTypeObj = getMediaTypeFromParam(mediaType);
return [query(mediaTypeObj, sortBy?.split("-")[0] as SortBy, sortBy?.split("-")[1] as SortOrd)];
BrowsePage.query = (
filter?: string,
sortKey?: SortBy,
sortOrd?: 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";
import Movie from "@material-symbols/svg-400/rounded/movie.svg";
import TV from "@material-symbols/svg-400/rounded/tv.svg";
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,
},
];
export const availableSorts = ["name", "startAir", "endAir", "createdAt", "rating"] as const;
export type SortBy = (typeof availableSorts)[number];
export type SortOrd = "asc" | "desc";