From 76229adeca001e1ca4f7e7ab4210b3dbe74fca39 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 30 Mar 2026 20:09:24 +0200 Subject: [PATCH] Implement filters for genres, staff and studios --- front/src/primitives/combobox.web.tsx | 8 +- front/src/primitives/menu.tsx | 4 +- front/src/primitives/menu.web.tsx | 12 +- front/src/primitives/select.web.tsx | 8 +- front/src/ui/admin/videos-modal/headers.tsx | 1 - front/src/ui/browse/header.tsx | 192 +++++++++++++++++++- 6 files changed, 205 insertions(+), 20 deletions(-) diff --git a/front/src/primitives/combobox.web.tsx b/front/src/primitives/combobox.web.tsx index f344ec7e..2bb45cef 100644 --- a/front/src/primitives/combobox.web.tsx +++ b/front/src/primitives/combobox.web.tsx @@ -10,7 +10,7 @@ import { cn } from "~/utils"; import type { ComboBoxProps } from "./combobox"; import { Icon } from "./icons"; import { PressableFeedback } from "./links"; -import { InternalTriger } from "./menu.web"; +import { InternalTrigger } from "./menu.web"; import { Skeleton } from "./skeleton"; import { P } from "./text"; @@ -59,9 +59,9 @@ export const ComboBox = ({ > {Trigger ? ( - + ) : ( - ({ className="group-focus-within:fill-slate-200 group-hover:fill-slate-200" /> - + )} diff --git a/front/src/primitives/menu.tsx b/front/src/primitives/menu.tsx index b1b31105..c6ee7b8a 100644 --- a/front/src/primitives/menu.tsx +++ b/front/src/primitives/menu.tsx @@ -107,12 +107,14 @@ const MenuItem = ({ href, icon, disabled, + closeOnSelect = true, ...props }: { label: string; selected?: boolean; left?: ReactElement; disabled?: boolean; + closeOnSelect?: boolean; icon?: ComponentType; } & ( | { onSelect: () => void; href?: undefined } @@ -131,7 +133,7 @@ const MenuItem = ({ return ( { - setOpen?.call(null, false); + if (closeOnSelect) setOpen?.call(null, false); onSelect?.call(null); if (href) router.push(href); }} diff --git a/front/src/primitives/menu.web.tsx b/front/src/primitives/menu.web.tsx index dcf58c09..0c4186dd 100644 --- a/front/src/primitives/menu.web.tsx +++ b/front/src/primitives/menu.web.tsx @@ -13,7 +13,7 @@ import { Icon } from "./icons"; import { useLinkTo } from "./links"; import { P } from "./text"; -export const InternalTriger = forwardRef(function _Triger( +export const InternalTrigger = forwardRef(function _Triger( { Component, ComponentProps, ...props }, ref, ) { @@ -23,6 +23,7 @@ export const InternalTriger = forwardRef(function _Triger( {...ComponentProps} {...props} onClickCapture={props.onPointerDown} + onPress={props.onPress ?? props.onClick} /> ); }); @@ -54,12 +55,12 @@ const Menu = ({ }} > - + e.stopImmediatePropagation()} - className="z-10 min-w-2xs overflow-hidden rounded bg-popover shadow-xl" + className="z-10 min-w-2xs overflow-y-auto rounded bg-popover shadow-xl" style={{ maxHeight: "calc(var(--radix-dropdown-menu-content-available-height) * 0.8)", @@ -81,6 +82,7 @@ const MenuItem = forwardRef< left?: ReactElement; disabled?: boolean; selected?: boolean; + closeOnSelect?: boolean; className?: string; } & ( | { onSelect: () => void; href?: undefined } @@ -95,6 +97,7 @@ const MenuItem = forwardRef< onSelect, href, disabled, + closeOnSelect = true, className, ...props }, @@ -117,7 +120,8 @@ const MenuItem = forwardRef< { + onSelect={(e) => { + if (!closeOnSelect) e.preventDefault(); onSelect?.(); onPress?.(undefined!); }} diff --git a/front/src/primitives/select.web.tsx b/front/src/primitives/select.web.tsx index 6e89b2ef..6671ddc7 100644 --- a/front/src/primitives/select.web.tsx +++ b/front/src/primitives/select.web.tsx @@ -7,7 +7,7 @@ import { Platform, View } from "react-native"; import { cn } from "~/utils"; import { Icon } from "./icons"; import { PressableFeedback } from "./links"; -import { InternalTriger } from "./menu.web"; +import { InternalTrigger } from "./menu.web"; import { P } from "./text"; export const Select = ({ @@ -26,7 +26,7 @@ export const Select = ({ return ( - - + ( > {label} - )} diff --git a/front/src/ui/browse/header.tsx b/front/src/ui/browse/header.tsx index 91c835dc..8c246512 100644 --- a/front/src/ui/browse/header.tsx +++ b/front/src/ui/browse/header.tsx @@ -1,16 +1,24 @@ import ArrowDownward from "@material-symbols/svg-400/rounded/arrow_downward.svg"; import ArrowUpward from "@material-symbols/svg-400/rounded/arrow_upward.svg"; +import Check from "@material-symbols/svg-400/rounded/check.svg"; +import Close from "@material-symbols/svg-400/rounded/close.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 Person from "@material-symbols/svg-400/rounded/person.svg"; import Sort from "@material-symbols/svg-400/rounded/sort.svg"; +import TheaterComedy from "@material-symbols/svg-400/rounded/theater_comedy.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 type { ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { type PressableProps, View } from "react-native"; +import type { SvgProps } from "react-native-svg"; +import { Genre, Staff, Studio } from "~/models"; import { + ComboBox, HR, Icon, IconButton, @@ -73,6 +81,34 @@ const MediaTypeTrigger = ({ ); }; +const FilterTrigger = ({ + label, + count, + icon, + className, + ...props +}: { + label: string; + count: number; + icon?: ComponentType; +} & PressableProps) => { + return ( + + +

+ {label} + {count > 0 ? ` (${count})` : ""} +

+
+ ); +}; + +const parseFilterValues = (filter: string, pattern: RegExp) => + Array.from(filter.matchAll(pattern)).map((x) => x[1]); + export const BrowseSettings = ({ sortBy, sortOrd, @@ -92,14 +128,56 @@ export const BrowseSettings = ({ }) => { const { t } = useTranslation(); - // TODO: have a proper filter frontend const mediaType = /kind eq (\w+)/.exec(filter)?.[1] ?? "all"; - const setMediaType = (kind: string) => - setFilter(kind !== "all" ? `kind eq ${kind}` : ""); + const includedGenres = parseFilterValues( + filter, + /(? { + const clauses: string[] = []; + kind ??= mediaType; + nextIncludedGenres ??= includedGenres; + nextExcludedGenres ??= excludedGenres; + nextStudios ??= studioSlugs; + nextStaff ??= staffSlugs; + + if (kind !== "all") clauses.push(`kind eq ${kind}`); + for (const studio of nextStudios) clauses.push(`studios has ${studio}`); + for (const person of nextStaff) clauses.push(`staff has ${person}`); + for (const genre of nextIncludedGenres) clauses.push(`genres has ${genre}`); + for (const genre of nextExcludedGenres) + clauses.push(`not genres has ${genre}`); + setFilter(clauses.join(" and ")); + }; return ( - - + + setMediaType(x)} + onSelect={() => applyFilters({ kind: x })} /> ))} + ( + + )} + > + {Genre.options.map((genre) => { + const isIncluded = includedGenres.includes(genre); + const isExcluded = excludedGenres.includes(genre); + return ( + + {(isIncluded || isExcluded) && ( + + )} + + } + closeOnSelect={false} + onSelect={() => { + let nextIncluded = includedGenres; + let nextExcluded = excludedGenres; + if (isIncluded) { + // include -> exclude + nextIncluded = nextIncluded.filter((g) => g !== genre); + nextExcluded = [...nextExcluded, genre]; + } else if (isExcluded) { + // exclude -> neutral + nextExcluded = nextExcluded.filter((g) => g !== genre); + } else { + // neutral -> include + nextIncluded = [...nextIncluded, genre]; + } + applyFilters({ + nextIncludedGenres: nextIncluded, + nextExcludedGenres: nextExcluded, + }); + }} + /> + ); + })} + + ( + + )} + query={(search) => ({ + path: ["api", "studios"], + parser: Studio, + infinite: true, + params: { + query: search, + }, + })} + values={studioSlugs.map((x) => ({ slug: x, name: x }))} + getKey={(studio) => studio.slug} + getLabel={(studio) => studio.name} + onValueChange={(items) => + applyFilters({ nextStudios: items.map((item) => item.slug) }) + } + /> + ( + + )} + query={(search) => ({ + path: ["api", "staff"], + parser: Staff, + infinite: true, + params: { + query: search, + }, + })} + values={staffSlugs.map((x) => ({ slug: x, name: x }))} + getKey={(member) => member.slug} + getLabel={(member) => member.name} + onValueChange={(items) => + applyFilters({ nextStaff: items.map((item) => item.slug) }) + } + />