From 69c4e4e6d8b7b9a240df2083624bbf8667d5f15a Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Fri, 20 Jun 2025 19:05:41 +0200 Subject: [PATCH] Rework browse settings --- front/i18n-d.d.ts | 9 -- front/src/components/index.ts | 2 - .../components/{ => items}/context-menus.tsx | 25 +--- front/src/components/items/index.ts | 23 ++++ .../src/components/{ => items}/item-grid.tsx | 0 .../components/{ => items}/item-helpers.tsx | 0 .../src/components/{ => items}/item-list.tsx | 0 .../components/{ => items}/watchlist-info.tsx | 30 +++-- front/src/models/show.ts | 1 + front/src/ui/browse/header.tsx | 124 +++++++++--------- front/src/ui/browse/index.tsx | 107 +++++---------- front/src/ui/browse/types.ts | 67 +--------- 12 files changed, 138 insertions(+), 250 deletions(-) delete mode 100644 front/i18n-d.d.ts delete mode 100644 front/src/components/index.ts rename front/src/components/{ => items}/context-menus.tsx (81%) create mode 100644 front/src/components/items/index.ts rename front/src/components/{ => items}/item-grid.tsx (100%) rename front/src/components/{ => items}/item-helpers.tsx (100%) rename front/src/components/{ => items}/item-list.tsx (100%) rename front/src/components/{ => items}/watchlist-info.tsx (78%) diff --git a/front/i18n-d.d.ts b/front/i18n-d.d.ts deleted file mode 100644 index b9fe1223..00000000 --- a/front/i18n-d.d.ts +++ /dev/null @@ -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 }; - } -} diff --git a/front/src/components/index.ts b/front/src/components/index.ts deleted file mode 100644 index d41506ac..00000000 --- a/front/src/components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./item-grid"; -export * from "./item-list"; diff --git a/front/src/components/context-menus.tsx b/front/src/components/items/context-menus.tsx similarity index 81% rename from front/src/components/context-menus.tsx rename to front/src/components/items/context-menus.tsx index 8c6a2fc7..a0904373 100644 --- a/front/src/components/context-menus.tsx +++ b/front/src/components/items/context-menus.tsx @@ -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 . - */ - 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["status"]; + export const EpisodesContext = ({ type = "episode", slug, diff --git a/front/src/components/items/index.ts b/front/src/components/items/index.ts new file mode 100644 index 00000000..5d67691b --- /dev/null +++ b/front/src/components/items/index.ts @@ -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 & ComponentProps => ({ + 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 }; diff --git a/front/src/components/item-grid.tsx b/front/src/components/items/item-grid.tsx similarity index 100% rename from front/src/components/item-grid.tsx rename to front/src/components/items/item-grid.tsx diff --git a/front/src/components/item-helpers.tsx b/front/src/components/items/item-helpers.tsx similarity index 100% rename from front/src/components/item-helpers.tsx rename to front/src/components/items/item-helpers.tsx diff --git a/front/src/components/item-list.tsx b/front/src/components/items/item-list.tsx similarity index 100% rename from front/src/components/item-list.tsx rename to front/src/components/items/item-list.tsx diff --git a/front/src/components/watchlist-info.tsx b/front/src/components/items/watchlist-info.tsx similarity index 78% rename from front/src/components/watchlist-info.tsx rename to front/src/components/items/watchlist-info.tsx index c46d27f8..e884e4a3 100644 --- a/front/src/components/watchlist-info.tsx +++ b/front/src/components/items/watchlist-info.tsx @@ -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["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["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 ( mutation.mutate(WatchStatusV.Planned)} + onPress={() => mutation.mutate("planned")} {...tooltip(t("show.watchlistAdd"))} {...props} /> ); - case WatchStatusV.Completed: + case "completed": return ( ); - case WatchStatusV.Planned: - case WatchStatusV.Watching: - case WatchStatusV.Droped: + case "planned": + case "watching": + case "rewatching": + case "dropped": return ( - {Object.values(WatchStatusV).map((x) => ( + {Object.values(WatchStatus).map((x) => ( }`)} + label={t(`show.watchlistMark.${x}`)} onSelect={() => mutation.mutate(x)} selected={x === status} /> diff --git a/front/src/models/show.ts b/front/src/models/show.ts index 8ccf11b5..3fcf719d 100644 --- a/front/src/models/show.ts +++ b/front/src/models/show.ts @@ -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; diff --git a/front/src/ui/browse/header.tsx b/front/src/ui/browse/header.tsx index a39f2cb0..2fd93f82 100644 --- a/front/src/ui/browse/header.tsx +++ b/front/src/ui/browse/header.tsx @@ -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(function SortTrigger( - { sortKey, ...props }, - ref, -) { +const SortTrigger = ({ sortBy, ...props }: { sortBy: SortBy } & PressableProps) => { const { css } = useYoshiki(); const { t } = useTranslation(); return ( -

{t(`browse.sortkey.${sortKey}` as any)}

+

{t(`browse.sortkey.${sortBy}`)}

); -}); +}; -const MediaTypeTrigger = forwardRef( - function MediaTypeTrigger({ mediaType, ...props }, ref) { - 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 ( - - -

{t(labelKey as any)}

-
- ); - }, -); +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(); + + return ( + + +

{t(mediaType !== "all" ? `browse.mediatypekey.${mediaType}` : "browse.mediatypelabel")}

+
+ ); +}; 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 ( - + {availableSorts.map((x) => ( - 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")} /> ))}
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" })} /> 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" })} /> - - {availableMediaTypes.map((x) => ( + + {Object.keys(MediaTypeIcons).map((x) => ( setMediaType(x)} /> ))} diff --git a/front/src/ui/browse/index.tsx b/front/src/ui/browse/index.tsx index 3add7ee1..641825a8 100644 --- a/front/src/ui/browse/index.tsx +++ b/front/src/ui/browse/index.tsx @@ -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 & ComponentProps => ({ - 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 => { - 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 ( { 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 => { + return { + parser: Show, + path: ["shows"], + infinite: true, + params: { + sort: sortKey ? `${sortKey}:${sortOrd ?? "asc"}` : "name:asc", + filter, + }, + }; }; diff --git a/front/src/ui/browse/types.ts b/front/src/ui/browse/types.ts index 81888723..178ab814 100644 --- a/front/src/ui/browse/types.ts +++ b/front/src/ui/browse/types.ts @@ -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; -} - -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";