Implement filters for genres, staff and studios

This commit is contained in:
Zoe Roux 2026-03-30 20:09:24 +02:00
parent 4bcdc98647
commit 76229adeca
No known key found for this signature in database
6 changed files with 205 additions and 20 deletions

View File

@ -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 = <Data,>({
>
<Popover.Trigger aria-label={label} asChild>
{Trigger ? (
<InternalTriger Component={Trigger} />
<InternalTrigger Component={Trigger} />
) : (
<InternalTriger
<InternalTrigger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
@ -83,7 +83,7 @@ export const ComboBox = <Data,>({
className="group-focus-within:fill-slate-200 group-hover:fill-slate-200"
/>
</View>
</InternalTriger>
</InternalTrigger>
)}
</Popover.Trigger>
<Popover.Portal>

View File

@ -107,12 +107,14 @@ const MenuItem = ({
href,
icon,
disabled,
closeOnSelect = true,
...props
}: {
label: string;
selected?: boolean;
left?: ReactElement;
disabled?: boolean;
closeOnSelect?: boolean;
icon?: ComponentType<SvgProps>;
} & (
| { onSelect: () => void; href?: undefined }
@ -131,7 +133,7 @@ const MenuItem = ({
return (
<PressableFeedback
onPress={() => {
setOpen?.call(null, false);
if (closeOnSelect) setOpen?.call(null, false);
onSelect?.call(null);
if (href) router.push(href);
}}

View File

@ -13,7 +13,7 @@ import { Icon } from "./icons";
import { useLinkTo } from "./links";
import { P } from "./text";
export const InternalTriger = forwardRef<unknown, any>(function _Triger(
export const InternalTrigger = forwardRef<unknown, any>(function _Triger(
{ Component, ComponentProps, ...props },
ref,
) {
@ -23,6 +23,7 @@ export const InternalTriger = forwardRef<unknown, any>(function _Triger(
{...ComponentProps}
{...props}
onClickCapture={props.onPointerDown}
onPress={props.onPress ?? props.onClick}
/>
);
});
@ -54,12 +55,12 @@ const Menu = <AsProps extends { onPress: PressableProps["onPress"] }>({
}}
>
<DropdownMenu.Trigger asChild>
<InternalTriger Component={Trigger} {...props} />
<InternalTrigger Component={Trigger} {...props} />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
onFocusOutside={(e) => 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<
<DropdownMenu.Item
ref={ref}
{...linkProps}
onSelect={() => {
onSelect={(e) => {
if (!closeOnSelect) e.preventDefault();
onSelect?.();
onPress?.(undefined!);
}}

View File

@ -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 (
<RSelect.Root value={value} onValueChange={onValueChange}>
<RSelect.Trigger aria-label={label} asChild>
<InternalTriger
<InternalTrigger
Component={Platform.OS === "web" ? "div" : PressableFeedback}
className={cn(
"group flex-row items-center justify-center overflow-hidden rounded-4xl",
@ -44,7 +44,7 @@ export const Select = ({
/>
</RSelect.Icon>
</View>
</InternalTriger>
</InternalTrigger>
</RSelect.Trigger>
<RSelect.Portal>
<RSelect.Content
@ -86,7 +86,7 @@ const Item = forwardRef<HTMLDivElement, { label: string; value: string }>(
>
<RSelect.ItemText className={cn()}>{label}</RSelect.ItemText>
<RSelect.ItemIndicator asChild>
<InternalTriger
<InternalTrigger
Component={Icon}
icon={Check}
className={cn(

View File

@ -106,7 +106,6 @@ export const AddVideoFooter = ({
icon={LibraryAdd}
text={t("videos-map.add")}
className="m-6 mt-10"
onPress={props.onPress ?? (props as any).onClick}
{...props}
/>
)}

View File

@ -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<SvgProps>;
} & PressableProps) => {
return (
<PressableFeedback
className={cn("flex-row items-center", className)}
{...props}
>
<Icon icon={icon ?? FilterList} className="mx-1" />
<P>
{label}
{count > 0 ? ` (${count})` : ""}
</P>
</PressableFeedback>
);
};
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,
/(?<!not )genres (?:has|eq) ([a-z-]+)/g,
);
const excludedGenres = parseFilterValues(
filter,
/not genres (?:has|eq) ([a-z-]+)/g,
);
const studioSlugs = parseFilterValues(
filter,
/studios (?:has|eq) ([a-z0-9-]+)/g,
);
const staffSlugs = parseFilterValues(
filter,
/staff (?:has|eq) ([a-z0-9-]+)/g,
);
const applyFilters = ({
kind,
nextIncludedGenres,
nextExcludedGenres,
nextStudios,
nextStaff,
}: {
kind?: string;
nextIncludedGenres?: string[];
nextExcludedGenres?: string[];
nextStudios?: string[];
nextStaff?: string[];
}) => {
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 (
<View className="mx-8 my-2 flex-row items-center justify-between">
<View className="flex-row">
<View className="mx-8 my-2 flex-1 flex-row flex-wrap items-center justify-between">
<View className="flex-row gap-3">
<Menu
Trigger={MediaTypeTrigger}
mediaType={mediaType as keyof typeof MediaTypeIcons}
@ -112,10 +190,112 @@ export const BrowseSettings = ({
)}
selected={mediaType === x}
icon={MediaTypeIcons[x as keyof typeof MediaTypeIcons]}
onSelect={() => setMediaType(x)}
onSelect={() => applyFilters({ kind: x })}
/>
))}
</Menu>
<Menu
Trigger={(props: PressableProps) => (
<FilterTrigger
label={t("show.genre")}
count={includedGenres.length + excludedGenres.length}
icon={TheaterComedy}
{...props}
/>
)}
>
{Genre.options.map((genre) => {
const isIncluded = includedGenres.includes(genre);
const isExcluded = excludedGenres.includes(genre);
return (
<Menu.Item
key={genre}
label={t(`genres.${genre}`)}
left={
<View className="h-6 w-6 items-center justify-center">
{(isIncluded || isExcluded) && (
<Icon icon={isExcluded ? Close : Check} />
)}
</View>
}
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,
});
}}
/>
);
})}
</Menu>
<ComboBox
multiple
label={t("show.studios")}
searchPlaceholder={t("navbar.search")}
Trigger={(props) => (
<FilterTrigger
label={t("show.studios")}
count={studioSlugs.length}
icon={TV}
{...props}
/>
)}
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) })
}
/>
<ComboBox
multiple
label={t("show.staff")}
searchPlaceholder={t("navbar.search")}
Trigger={(props) => (
<FilterTrigger
label={t("show.staff")}
count={staffSlugs.length}
icon={Person}
{...props}
/>
)}
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) })
}
/>
</View>
<View className="flex-row">
<Menu Trigger={SortTrigger} sortBy={sortBy}>