Repository: IgnisDa/ryot (language: rust)

Issue:
Fading and hiding lists items based on filters. Also expand filters on lists.
This feature would perform a filter to fade or hide items from lists. It is heavily inspired by the trakt.tv feature. The first component would be to expand filter options on lists, as right now it just sorts. This would need to be able to filter out based on metadata and whether it appears on other built-in lists. This would take care of the "hide" part of the filter. These are the filter options as they appear on trakt.tv.

![Image](https://github.com/user-attachments/assets/a1e4caa1-30a0-4076-96ac-d06c29f428f3)

The fade feature would still have the item appear in the list but it's appearance would be faded so it's obvious that it has been filtered but isn't excluded from the list. This helps you go through curated lists and find movies that you're missing for example. This is how it appears on trakt.tv.

![Image](https://github.com/user-attachments/assets/77cb61cb-6215-42c8-9195-ea79d29a53d9)


Discussion / hints:
can the elements be interacted with when they are in a a faded state?


There is a mouseover event that removes the fade. Other than that the item can be interacted with normally. 

Fix patch:
diff --git a/apps/frontend/app/components/common.tsx b/apps/frontend/app/components/common.tsx
index df895e1837..b23874d8cf 100644
--- a/apps/frontend/app/components/common.tsx
+++ b/apps/frontend/app/components/common.tsx
@@ -9,7 +9,6 @@ import {
 	Box,
 	Button,
 	Center,
-	Checkbox,
 	Collapse,
 	Divider,
 	Flex,
@@ -18,9 +17,9 @@ import {
 	Loader,
 	type MantineStyleProp,
 	Modal,
-	MultiSelect,
 	Paper,
 	RingProgress,
+	Select,
 	SimpleGrid,
 	Skeleton,
 	Stack,
@@ -28,13 +27,20 @@ import {
 	TextInput,
 	Title,
 	Tooltip,
-	rem,
 	useMantineTheme,
 } from "@mantine/core";
-import { useDebouncedValue, useDidUpdate, useDisclosure } from "@mantine/hooks";
+import {
+	randomId,
+	useDebouncedValue,
+	useDidUpdate,
+	useDisclosure,
+	useListState,
+} from "@mantine/hooks";
 import {
 	EntityLot,
 	GridPacking,
+	type MediaCollectionFilter,
+	MediaCollectionPresenceFilter,
 	MediaLot,
 	type MediaSource,
 	type ReviewItem,
@@ -49,6 +55,7 @@ import {
 	isNumber,
 	isString,
 	snakeCase,
+	startCase,
 } from "@ryot/ts-utils";
 import {
 	IconArrowBigUp,
@@ -62,6 +69,7 @@ import {
 	IconMoodEmpty,
 	IconMoodHappy,
 	IconMoodSad,
+	IconPlus,
 	IconRefresh,
 	IconRotateClockwise,
 	IconScaleOutline,
@@ -73,6 +81,7 @@ import {
 } from "@tabler/icons-react";
 import { useQuery } from "@tanstack/react-query";
 import clsx from "clsx";
+import { produce } from "immer";
 import Cookies from "js-cookie";
 import type { ReactNode, Ref } from "react";
 import { Fragment, useState } from "react";
@@ -505,39 +514,105 @@ export const FiltersModal = (props: {
 
 export const CollectionsFilter = (props: {
 	cookieName: string;
-	collections?: string[];
-	invertCollection?: boolean;
+	applied: MediaCollectionFilter[];
 }) => {
+	const coreDetails = useCoreDetails();
 	const collections = useNonHiddenUserCollections();
-	const [_, { setP }] = useAppSearchParam(props.cookieName);
+	const [_p, { setP }] = useAppSearchParam(props.cookieName);
+	const [filters, filtersHandlers] = useListState<
+		MediaCollectionFilter & { id: string }
+	>((props.applied || []).map((a) => ({ ...a, id: randomId() })));
+
+	useDidUpdate(() => {
+		if (!coreDetails.isServerKeyValidated) return;
+		const final = filters
+			.filter((f) => f.collectionId)
+			.map((a) => `${a.collectionId}:${a.presence}`)
+			.join(",");
+		setP("collections", final);
+	}, [filters]);
 
 	return (
-		<MultiSelect
-			flex={1}
-			clearable
-			searchable
-			rightSectionWidth={rem(100)}
-			rightSectionPointerEvents="all"
-			defaultValue={props.collections}
-			placeholder="Select a collection"
-			onChange={(v) => setP("collections", v.join(","))}
-			data={[
-				{
-					group: "My collections",
-					items: collections.map((c) => ({
-						label: c.name,
-						value: c.id.toString(),
-					})),
-				},
-			]}
-			rightSection={
-				<Checkbox
-					label="Invert"
-					checked={props.invertCollection}
-					onChange={(e) => setP("invertCollection", String(e.target.checked))}
-				/>
-			}
-		/>
+		<Stack gap="sm">
+			<Group wrap="nowrap" justify="space-between">
+				<Text size="sm" c="dimmed">
+					Collection filters
+				</Text>
+				<Button
+					size="compact-xs"
+					variant="transparent"
+					leftSection={<IconPlus size={14} />}
+					onClick={() =>
+						filtersHandlers.append({
+							id: randomId(),
+							collectionId: "",
+							presence: MediaCollectionPresenceFilter.PresentIn,
+						})
+					}
+				>
+					Add
+				</Button>
+			</Group>
+			{filters.length > 0 ? (
+				<>
+					<Stack gap="xs" px={{ md: "xs" }}>
+						{filters.map((f, idx) => (
+							<Group key={f.id} justify="space-between" wrap="nowrap">
+								{idx !== 0 ? (
+									<Text size="xs" c="dimmed">
+										OR
+									</Text>
+								) : null}
+								<Select
+									size="xs"
+									value={f.presence}
+									data={Object.values(MediaCollectionPresenceFilter).map(
+										(o) => ({
+											value: o,
+											label: startCase(o.toLowerCase()),
+										}),
+									)}
+									onChange={(v) =>
+										filtersHandlers.setItem(
+											idx,
+											produce(f, (d) => {
+												d.presence = v as MediaCollectionPresenceFilter;
+											}),
+										)
+									}
+								/>
+								<Select
+									size="xs"
+									searchable
+									value={f.collectionId}
+									placeholder="Select a collection"
+									data={collections.map((c) => ({
+										label: c.name,
+										value: c.id.toString(),
+									}))}
+									onChange={(v) =>
+										filtersHandlers.setItem(
+											idx,
+											produce(f, (d) => {
+												d.collectionId = v || "";
+											}),
+										)
+									}
+								/>
+								<ActionIcon
+									size="xs"
+									color="red"
+									onClick={() => filtersHandlers.remove(idx)}
+								>
+									<IconX />
+								</ActionIcon>
+							</Group>
+						))}
+					</Stack>
+					<ProRequiredAlert />
+				</>
+			) : null}
+		</Stack>
 	);
 };
 
diff --git a/apps/frontend/app/lib/generals.tsx b/apps/frontend/app/lib/generals.tsx
index 446418e331..be155cf6e3 100644
--- a/apps/frontend/app/lib/generals.tsx
+++ b/apps/frontend/app/lib/generals.tsx
@@ -6,6 +6,8 @@ import {
 import { type MantineColor, Text } from "@mantine/core";
 import { modals } from "@mantine/modals";
 import {
+	type MediaCollectionFilter,
+	type MediaCollectionPresenceFilter,
 	MediaLot,
 	MediaSource,
 	MetadataDetailsDocument,
@@ -72,6 +74,20 @@ export const zodEmptyDecimalString = z
 	.transform((v) => (!v ? undefined : Number.parseFloat(v).toString()))
 	.nullable();
 
+export const zodCollectionFilter = zodCommaDelimitedString.transform(
+	(v) =>
+		(v || [])
+			.map((s) => {
+				const [collectionId, presence] = s.split(":");
+				if (!collectionId || !presence) return undefined;
+				return {
+					collectionId,
+					presence: presence as MediaCollectionPresenceFilter,
+				};
+			})
+			.filter(Boolean) as MediaCollectionFilter[],
+);
+
 export const LOGO_IMAGE_URL =
 	"https://raw.githubusercontent.com/IgnisDa/ryot/main/libs/assets/icon-512x512.png";
 export const redirectToQueryParam = "redirectTo";
diff --git a/apps/frontend/app/routes/_dashboard.collections.list.tsx b/apps/frontend/app/routes/_dashboard.collections.list.tsx
index 4deb69a839..ef24028409 100644
--- a/apps/frontend/app/routes/_dashboard.collections.list.tsx
+++ b/apps/frontend/app/routes/_dashboard.collections.list.tsx
@@ -209,8 +209,14 @@ export default function Page() {
 	const [toUpdateCollection, setToUpdateCollection] =
 		useState<UpdateCollectionInput | null>(null);
 	const [params, { setP }] = useAppSearchParam(loaderData.cookieName);
+
 	const query = params.get("query") || undefined;
 	const showHidden = Boolean(params.get("showHidden"));
+	const hasHiddenCollections = collections.some(
+		(c) =>
+			c.collaborators.find((c) => c.collaborator.id === userDetails.id)
+				?.extraInformation?.isHidden,
+	);
 
 	useEffect(() => {
 		if (transition.state !== "submitting") setToUpdateCollection(null);
@@ -259,13 +265,17 @@ export default function Page() {
 						initialValue={query}
 						enhancedQueryParams={loaderData.cookieName}
 					/>
-					<Checkbox
-						size="xs"
-						name="showHidden"
-						label="Show hidden"
-						defaultChecked={showHidden}
-						onChange={(e) => setP("showHidden", e.target.checked ? "yes" : "")}
-					/>
+					{hasHiddenCollections ? (
+						<Checkbox
+							size="xs"
+							name="showHidden"
+							label="Show hidden"
+							defaultChecked={showHidden}
+							onChange={(e) =>
+								setP("showHidden", e.target.checked ? "yes" : "")
+							}
+						/>
+					) : null}
 				</Group>
 				<Virtuoso
 					style={{ height: "80vh" }}
diff --git a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
index 5affe35d99..e86498a3db 100644
--- a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
@@ -31,11 +31,11 @@ import {
 } from "@ryot/generated/graphql/backend/graphql";
 import {
 	changeCase,
+	isEqual,
 	parseParameters,
 	parseSearchQuery,
 	snakeCase,
 	startCase,
-	zodBoolAsString,
 	zodIntAsString,
 } from "@ryot/ts-utils";
 import {
@@ -52,6 +52,7 @@ import {
 import { useState } from "react";
 import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
 import { $path } from "safe-routes";
+import invariant from "tiny-invariant";
 import { match } from "ts-pattern";
 import { withoutHost } from "ufo";
 import { z } from "zod";
@@ -73,7 +74,7 @@ import {
 	getStartTimeFromRange,
 	getVerb,
 	pageQueryParam,
-	zodCommaDelimitedString,
+	zodCollectionFilter,
 } from "~/lib/generals";
 import {
 	useAppSearchParam,
@@ -101,7 +102,7 @@ export type SearchParams = {
 };
 
 const defaultFilters = {
-	mineCollection: undefined,
+	mineCollections: [],
 	mineSortBy: MediaSortBy.LastSeen,
 	mineSortOrder: GraphqlSortOrder.Desc,
 	mineGeneralFilter: MediaGeneralFilter.All,
@@ -134,10 +135,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 	const [totalResults, mediaList, mediaSearch] = await match(action)
 		.with(Action.List, async () => {
 			const listSchema = z.object({
-				collections: zodCommaDelimitedString,
+				collections: zodCollectionFilter,
 				endDateRange: z.string().optional(),
 				startDateRange: z.string().optional(),
-				invertCollection: zodBoolAsString.optional(),
 				sortBy: z.nativeEnum(MediaSortBy).default(defaultFilters.mineSortBy),
 				dateRange: z
 					.nativeEnum(ApplicationTimeRange)
@@ -156,7 +156,6 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 				{
 					input: {
 						lot,
-						invertCollection: urlParse.invertCollection,
 						sort: { order: urlParse.sortOrder, by: urlParse.sortBy },
 						search: { page: query[pageQueryParam], query: query.query },
 						filter: {
@@ -181,7 +180,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 			const metadataSourcesForLot = coreDetails.metadataLotSourceMappings.find(
 				(m) => m.lot === lot,
 			);
-			if (!metadataSourcesForLot) throw new Error("Mapping not found");
+			invariant(metadataSourcesForLot);
 			const searchSchema = z.object({
 				source: z
 					.nativeEnum(MediaSource)
@@ -258,13 +257,16 @@ export default function Page() {
 
 	const bulkEditingState = bulkEditingCollection.state;
 	const mediaSearch = loaderData.mediaSearch;
-	const isFilterChanged =
+	const areFiltersApplied =
 		loaderData.mediaList?.url.generalFilter !==
 			defaultFilters.mineGeneralFilter ||
 		loaderData.mediaList?.url.sortOrder !== defaultFilters.mineSortOrder ||
 		loaderData.mediaList?.url.sortBy !== defaultFilters.mineSortBy ||
-		loaderData.mediaList?.url.collections !== defaultFilters.mineCollection ||
-		loaderData.mediaList?.url.dateRange !== defaultFilters.mineDateRange;
+		loaderData.mediaList?.url.dateRange !== defaultFilters.mineDateRange ||
+		!isEqual(
+			loaderData.mediaList?.url.collections,
+			defaultFilters.mineCollections,
+		);
 
 	return (
 		<Container>
@@ -324,7 +326,7 @@ export default function Page() {
 							/>
 							<ActionIcon
 								onClick={openFiltersModal}
-								color={isFilterChanged ? "blue" : "gray"}
+								color={areFiltersApplied ? "blue" : "gray"}
 							>
 								<IconFilter size={24} />
 							</ActionIcon>
@@ -630,10 +632,10 @@ const FiltersModalForm = () => {
 					)}
 				</ActionIcon>
 			</Flex>
+			<Divider />
 			<CollectionsFilter
 				cookieName={loaderData.cookieName}
-				collections={loaderData.mediaList.url.collections}
-				invertCollection={loaderData.mediaList.url.invertCollection}
+				applied={loaderData.mediaList.url.collections}
 			/>
 			<Divider />
 			<Stack gap="xs">
diff --git a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
index 440f9d7cf4..fc4434cc14 100644
--- a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
@@ -3,6 +3,7 @@ import {
 	Box,
 	Center,
 	Container,
+	Divider,
 	Flex,
 	Group,
 	Loader,
@@ -26,11 +27,10 @@ import {
 } from "@ryot/generated/graphql/backend/graphql";
 import {
 	changeCase,
-	isString,
+	isEqual,
 	parseParameters,
 	parseSearchQuery,
 	startCase,
-	zodBoolAsString,
 	zodIntAsString,
 } from "@ryot/ts-utils";
 import {
@@ -56,7 +56,7 @@ import {
 } from "~/components/common";
 import { BaseMediaDisplayItem } from "~/components/common";
 import { MetadataGroupDisplayItem } from "~/components/media";
-import { pageQueryParam, zodCommaDelimitedString } from "~/lib/generals";
+import { pageQueryParam, zodCollectionFilter } from "~/lib/generals";
 import { useAppSearchParam, useCoreDetails } from "~/lib/hooks";
 import { useBulkEditCollection } from "~/lib/state/collection";
 import {
@@ -72,8 +72,9 @@ export type SearchParams = {
 };
 
 const defaultFilters = {
-	sortBy: PersonAndMetadataGroupsSortBy.AssociatedEntityCount,
+	collections: [],
 	orderBy: GraphqlSortOrder.Desc,
+	sortBy: PersonAndMetadataGroupsSortBy.AssociatedEntityCount,
 };
 
 enum Action {
@@ -95,8 +96,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 	const [totalResults, list, search] = await match(action)
 		.with(Action.List, async () => {
 			const listSchema = z.object({
-				collections: zodCommaDelimitedString,
-				invertCollection: zodBoolAsString.optional(),
+				collections: zodCollectionFilter,
 				orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy),
 				sortBy: z
 					.nativeEnum(PersonAndMetadataGroupsSortBy)
@@ -109,7 +109,6 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 					UserMetadataGroupsListDocument,
 					{
 						input: {
-							invertCollection: urlParse.invertCollection,
 							filter: { collections: urlParse.collections },
 							sort: { by: urlParse.sortBy, order: urlParse.orderBy },
 							search: { page: query[pageQueryParam], query: query.query },
@@ -181,7 +180,12 @@ export default function Page() {
 		{ open: openFiltersModal, close: closeFiltersModal },
 	] = useDisclosure(false);
 	const bulkEditingCollection = useBulkEditCollection();
+
 	const bulkEditingState = bulkEditingCollection.state;
+	const areFiltersApplied =
+		loaderData.list?.url.orderBy !== defaultFilters.orderBy ||
+		loaderData.list?.url.sortBy !== defaultFilters.sortBy ||
+		!isEqual(loaderData.list?.url.collections, defaultFilters.collections);
 
 	return (
 		<Container>
@@ -224,13 +228,7 @@ export default function Page() {
 						<>
 							<ActionIcon
 								onClick={openFiltersModal}
-								color={
-									loaderData.list?.url.orderBy !== defaultFilters.orderBy ||
-									loaderData.list?.url.sortBy !== defaultFilters.sortBy ||
-									isString(loaderData.list?.url.collections)
-										? "blue"
-										: "gray"
-								}
+								color={areFiltersApplied ? "blue" : "gray"}
 							>
 								<IconFilter size={24} />
 							</ActionIcon>
@@ -427,10 +425,10 @@ const FiltersModalForm = () => {
 					)}
 				</ActionIcon>
 			</Flex>
+			<Divider />
 			<CollectionsFilter
 				cookieName={loaderData.cookieName}
-				collections={loaderData.list.url.collections}
-				invertCollection={loaderData.list.url.invertCollection}
+				applied={loaderData.list.url.collections}
 			/>
 		</>
 	);
diff --git a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
index 8f8091dedd..615b9085e6 100644
--- a/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.item.$id._index.tsx
@@ -8,6 +8,7 @@ import {
 	Button,
 	Container,
 	Divider,
+	Drawer,
 	Flex,
 	FocusTrap,
 	Group,
@@ -84,12 +85,14 @@ import {
 	type ReactNode,
 	type RefObject,
 	forwardRef,
+	useMemo,
 	useRef,
 	useState,
 } from "react";
 import { Form, Link, useLoaderData } from "react-router";
 import { Virtuoso, VirtuosoGrid, type VirtuosoHandle } from "react-virtuoso";
 import { $path } from "safe-routes";
+import invariant from "tiny-invariant";
 import { match } from "ts-pattern";
 import { withQuery } from "ufo";
 import { z } from "zod";
@@ -1255,9 +1258,18 @@ const DisplayShowSeasonEpisodesModal = (props: {
 	openedShowSeason: number | undefined;
 	setOpenedShowSeason: (v: number | undefined) => void;
 }) => {
+	const loaderData = useLoaderData<typeof loader>();
+	const title = useMemo(() => {
+		const showSpecifics = loaderData.metadataDetails.showSpecifics;
+		invariant(showSpecifics);
+		return isNumber(props.openedShowSeason)
+			? getShowSeasonDisplayName(showSpecifics.seasons[props.openedShowSeason])
+			: "";
+	}, [props.openedShowSeason]);
+
 	return (
-		<Modal
-			withCloseButton={false}
+		<Drawer
+			title={title}
 			opened={props.openedShowSeason !== undefined}
 			onClose={() => props.setOpenedShowSeason(undefined)}
 		>
@@ -1267,7 +1279,7 @@ const DisplayShowSeasonEpisodesModal = (props: {
 					setOpenedShowSeason={props.setOpenedShowSeason}
 				/>
 			) : null}
-		</Modal>
+		</Drawer>
 	);
 };
 
@@ -1282,21 +1294,24 @@ const DisplayShowSeasonEpisodes = (props: {
 		loaderData.userMetadataDetails.showProgress?.[props.openedShowSeason];
 
 	return isNumber(props.openedShowSeason) && season ? (
-		<Stack h={600} gap="xs">
-			<Title order={3}>{getShowSeasonDisplayName(season)}</Title>
-			<Virtuoso
-				data={season.episodes}
-				itemContent={(episodeIdx, episode) => (
-					<DisplayShowEpisode
-						episode={episode}
-						episodeIdx={episodeIdx}
-						seasonNumber={season.seasonNumber}
-						seasonIdx={props.openedShowSeason}
-						episodeProgress={seasonProgress?.episodes[episodeIdx]}
-						beforeOpenModal={() => props.setOpenedShowSeason(undefined)}
-					/>
-				)}
-			/>
+		<Stack h={{ base: "80vh", md: "90vh" }} gap="xs">
+			{season.episodes.length > 0 ? (
+				<Virtuoso
+					data={season.episodes}
+					itemContent={(episodeIdx, episode) => (
+						<DisplayShowEpisode
+							episode={episode}
+							episodeIdx={episodeIdx}
+							seasonNumber={season.seasonNumber}
+							seasonIdx={props.openedShowSeason}
+							episodeProgress={seasonProgress?.episodes[episodeIdx]}
+							beforeOpenModal={() => props.setOpenedShowSeason(undefined)}
+						/>
+					)}
+				/>
+			) : (
+				<Text>No episodes found</Text>
+			)}
 		</Stack>
 	) : null;
 };
diff --git a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
index 166c099ab7..cb6b42253b 100644
--- a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
@@ -4,6 +4,7 @@ import {
 	Center,
 	Checkbox,
 	Container,
+	Divider,
 	Flex,
 	Group,
 	Loader,
@@ -26,6 +27,7 @@ import {
 } from "@ryot/generated/graphql/backend/graphql";
 import {
 	changeCase,
+	isEqual,
 	parseParameters,
 	parseSearchQuery,
 	startCase,
@@ -54,7 +56,7 @@ import {
 } from "~/components/common";
 import { BaseMediaDisplayItem } from "~/components/common";
 import { PersonDisplayItem } from "~/components/media";
-import { pageQueryParam, zodCommaDelimitedString } from "~/lib/generals";
+import { pageQueryParam, zodCollectionFilter } from "~/lib/generals";
 import { useAppSearchParam, useCoreDetails } from "~/lib/hooks";
 import { useBulkEditCollection } from "~/lib/state/collection";
 import {
@@ -70,8 +72,9 @@ export type SearchParams = {
 };
 
 const defaultFilters = {
-	sortBy: PersonAndMetadataGroupsSortBy.AssociatedEntityCount,
+	collections: [],
 	orderBy: GraphqlSortOrder.Desc,
+	sortBy: PersonAndMetadataGroupsSortBy.AssociatedEntityCount,
 };
 
 enum Action {
@@ -101,8 +104,7 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 	const [totalResults, list, search] = await match(action)
 		.with(Action.List, async () => {
 			const listSchema = z.object({
-				collections: zodCommaDelimitedString,
-				invertCollection: zodBoolAsString.optional(),
+				collections: zodCollectionFilter,
 				orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy),
 				sortBy: z
 					.nativeEnum(PersonAndMetadataGroupsSortBy)
@@ -114,7 +116,6 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
 				UserPeopleListDocument,
 				{
 					input: {
-						invertCollection: urlParse.invertCollection,
 						filter: { collections: urlParse.collections },
 						sort: { by: urlParse.sortBy, order: urlParse.orderBy },
 						search: { page: query[pageQueryParam], query: query.query },
@@ -181,7 +182,12 @@ export default function Page() {
 		{ open: openFiltersModal, close: closeFiltersModal },
 	] = useDisclosure(false);
 	const bulkEditingCollection = useBulkEditCollection();
+
 	const bulkEditingState = bulkEditingCollection.state;
+	const areFiltersApplied =
+		loaderData.list?.url.orderBy !== defaultFilters.orderBy ||
+		loaderData.list?.url.sortBy !== defaultFilters.sortBy ||
+		!isEqual(loaderData.list?.url.collections, defaultFilters.collections);
 
 	return (
 		<Container>
@@ -225,12 +231,7 @@ export default function Page() {
 						<>
 							<ActionIcon
 								onClick={openFiltersModal}
-								color={
-									loaderData.list?.url.orderBy !== defaultFilters.orderBy ||
-									loaderData.list?.url.sortBy !== defaultFilters.sortBy
-										? "blue"
-										: "gray"
-								}
+								color={areFiltersApplied ? "blue" : "gray"}
 							>
 								<IconFilter size={24} />
 							</ActionIcon>
@@ -435,12 +436,12 @@ const FiltersModalForm = () => {
 			<Flex gap="xs" align="center">
 				<Select
 					w="100%"
+					onChange={(v) => setP("sortBy", v)}
+					defaultValue={loaderData.list.url.sortBy}
 					data={Object.values(PersonAndMetadataGroupsSortBy).map((o) => ({
 						value: o.toString(),
 						label: startCase(o.toLowerCase()),
 					}))}
-					defaultValue={loaderData.list.url.sortBy}
-					onChange={(v) => setP("sortBy", v)}
 				/>
 				<ActionIcon
 					onClick={() => {
@@ -456,10 +457,10 @@ const FiltersModalForm = () => {
 					)}
 				</ActionIcon>
 			</Flex>
+			<Divider />
 			<CollectionsFilter
 				cookieName={loaderData.cookieName}
-				collections={loaderData.list.url.collections}
-				invertCollection={loaderData.list.url.invertCollection}
+				applied={loaderData.list.url.collections}
 			/>
 		</>
 	);
diff --git a/crates/models/dependent/src/lib.rs b/crates/models/dependent/src/lib.rs
index f785955425..e177ad585e 100644
--- a/crates/models/dependent/src/lib.rs
+++ b/crates/models/dependent/src/lib.rs
@@ -267,7 +267,6 @@ pub struct UserMetadataListInput {
     pub lot: Option<MediaLot>,
     pub filter: Option<MediaFilter>,
     pub search: Option<SearchInput>,
-    pub invert_collection: Option<bool>,
     pub sort: Option<SortInput<MediaSortBy>>,
 }
 
@@ -275,7 +274,6 @@ pub struct UserMetadataListInput {
 pub struct UserPeopleListInput {
     pub search: Option<SearchInput>,
     pub filter: Option<MediaFilter>,
-    pub invert_collection: Option<bool>,
     pub sort: Option<SortInput<PersonAndMetadataGroupsSortBy>>,
 }
 
@@ -283,7 +281,6 @@ pub struct UserPeopleListInput {
 pub struct UserMetadataGroupsListInput {
     pub search: Option<SearchInput>,
     pub filter: Option<MediaFilter>,
-    pub invert_collection: Option<bool>,
     pub sort: Option<SortInput<PersonAndMetadataGroupsSortBy>>,
 }
 
diff --git a/crates/models/media/src/lib.rs b/crates/models/media/src/lib.rs
index 0130d5c227..d689d02538 100644
--- a/crates/models/media/src/lib.rs
+++ b/crates/models/media/src/lib.rs
@@ -1304,11 +1304,24 @@ pub enum MediaGeneralFilter {
     Unfinished,
 }
 
+#[derive(Debug, Hash, Serialize, Deserialize, Enum, Clone, Copy, Eq, PartialEq, Default)]
+pub enum MediaCollectionPresenceFilter {
+    #[default]
+    PresentIn,
+    NotPresentIn,
+}
+
+#[derive(Debug, Hash, PartialEq, Eq, Serialize, Deserialize, InputObject, Clone, Default)]
+pub struct MediaCollectionFilter {
+    pub collection_id: String,
+    pub presence: MediaCollectionPresenceFilter,
+}
+
 #[derive(Debug, Hash, PartialEq, Eq, Serialize, Deserialize, InputObject, Clone, Default)]
 pub struct MediaFilter {
-    pub collections: Option<Vec<String>>,
     pub general: Option<MediaGeneralFilter>,
     pub date_range: Option<ApplicationDateRange>,
+    pub collections: Option<Vec<MediaCollectionFilter>>,
 }
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
diff --git a/crates/utils/database/src/lib.rs b/crates/utils/database/src/lib.rs
index 1b1d1202a8..b5701ce9f1 100644
--- a/crates/utils/database/src/lib.rs
+++ b/crates/utils/database/src/lib.rs
@@ -26,10 +26,10 @@ use itertools::Itertools;
 use jwt_service::{verify, Claims};
 use markdown::to_html as markdown_to_html;
 use media_models::{
-    AnimeSpecifics, AudioBookSpecifics, BookSpecifics, MangaSpecifics, MovieSpecifics,
-    MusicSpecifics, PodcastSpecifics, ReviewItem, SeenAnimeExtraInformation,
-    SeenMangaExtraInformation, SeenPodcastExtraInformation, SeenShowExtraInformation,
-    ShowSpecifics, VideoGameSpecifics, VisualNovelSpecifics,
+    AnimeSpecifics, AudioBookSpecifics, BookSpecifics, MangaSpecifics, MediaCollectionFilter,
+    MediaCollectionPresenceFilter, MovieSpecifics, MusicSpecifics, PodcastSpecifics, ReviewItem,
+    SeenAnimeExtraInformation, SeenMangaExtraInformation, SeenPodcastExtraInformation,
+    SeenShowExtraInformation, ShowSpecifics, VideoGameSpecifics, VisualNovelSpecifics,
 };
 use migrations::AliasedCollectionToEntity;
 use rust_decimal::{prelude::ToPrimitive, Decimal};
@@ -219,45 +219,49 @@ pub async fn user_workout_template_details(
     })
 }
 
-pub fn apply_collection_filter<E, C, D>(
-    query: Select<E>,
-    collection_id: Option<Vec<String>>,
-    invert_collection: Option<bool>,
-    entity_column: C,
-    id_column: D,
-) -> Select<E>
+pub fn apply_collection_filter<C, D, E>(
+    id_column: C,
+    query: Select<D>,
+    entity_column: E,
+    collection_filters: Vec<MediaCollectionFilter>,
+) -> Select<D>
 where
-    E: EntityTrait,
     C: ColumnTrait,
-    D: ColumnTrait,
+    D: EntityTrait,
+    E: ColumnTrait,
 {
-    query.apply_if(collection_id, |query, v| {
-        let unique_collections = v.into_iter().unique().collect_vec();
-        let count = unique_collections.len() as i32;
-        let subquery = CollectionToEntity::find()
-            .select_only()
-            .column(id_column)
-            .filter(
-                Expr::col((
-                    AliasedCollectionToEntity::Table,
-                    collection_to_entity::Column::CollectionId,
-                ))
-                .eq(PgFunc::any(unique_collections)),
-            )
-            .filter(id_column.is_not_null())
-            .group_by(id_column)
-            .having(
-                collection_to_entity::Column::CollectionId
-                    .count()
-                    .eq(Expr::val(count)),
-            )
-            .into_query();
-        if invert_collection.unwrap_or_default() {
-            query.filter(entity_column.not_in_subquery(subquery))
-        } else {
-            query.filter(entity_column.in_subquery(subquery))
-        }
-    })
+    if collection_filters.is_empty() {
+        return query;
+    }
+    let is_in = collection_filters
+        .iter()
+        .filter(|f| f.presence == MediaCollectionPresenceFilter::PresentIn)
+        .map(|f| f.collection_id.clone())
+        .collect_vec();
+    let is_not_in = collection_filters
+        .iter()
+        .filter(|f| f.presence == MediaCollectionPresenceFilter::NotPresentIn)
+        .map(|f| f.collection_id.clone())
+        .collect_vec();
+    let subquery = CollectionToEntity::find()
+        .select_only()
+        .column(entity_column)
+        .filter(entity_column.is_not_null())
+        .filter(
+            Expr::col((
+                AliasedCollectionToEntity::Table,
+                collection_to_entity::Column::CollectionId,
+            ))
+            .is_in(is_in),
+        )
+        .filter(
+            Expr::col((
+                AliasedCollectionToEntity::Table,
+                collection_to_entity::Column::CollectionId,
+            ))
+            .is_not_in(is_not_in),
+        );
+    query.filter(id_column.in_subquery(subquery.into_query()))
 }
 
 pub fn user_claims_from_token(token: &str, jwt_secret: &str) -> Result<Claims> {
diff --git a/crates/utils/dependent/src/lib.rs b/crates/utils/dependent/src/lib.rs
index 2b76329abd..61597e9bc5 100644
--- a/crates/utils/dependent/src/lib.rs
+++ b/crates/utils/dependent/src/lib.rs
@@ -2878,11 +2878,10 @@ pub async fn user_metadata_list(
             input.filter.clone().and_then(|f| f.collections),
             |query, v| {
                 apply_collection_filter(
-                    query,
-                    Some(v),
-                    input.invert_collection,
                     metadata::Column::Id,
+                    query,
                     collection_to_entity::Column::MetadataId,
+                    v,
                 )
             },
         )
@@ -3015,11 +3014,10 @@ pub async fn user_metadata_groups_list(
             input.filter.clone().and_then(|f| f.collections),
             |query, v| {
                 apply_collection_filter(
-                    query,
-                    Some(v),
-                    input.invert_collection,
                     metadata_group::Column::Id,
+                    query,
                     collection_to_entity::Column::MetadataGroupId,
+                    v,
                 )
             },
         )
@@ -3103,11 +3101,10 @@ pub async fn user_people_list(
             input.filter.clone().and_then(|f| f.collections),
             |query, v| {
                 apply_collection_filter(
-                    query,
-                    Some(v),
-                    input.invert_collection,
                     person::Column::Id,
+                    query,
                     collection_to_entity::Column::PersonId,
+                    v,
                 )
             },
         )
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index 88a4a056e4..077bbadd04 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -1012,8 +1012,18 @@ export type MediaCollectionContentsResults = {
   items: Array<EntityWithLot>;
 };
 
+export type MediaCollectionFilter = {
+  collectionId: Scalars['String']['input'];
+  presence: MediaCollectionPresenceFilter;
+};
+
+export enum MediaCollectionPresenceFilter {
+  NotPresentIn = 'NOT_PRESENT_IN',
+  PresentIn = 'PRESENT_IN'
+}
+
 export type MediaFilter = {
-  collections?: InputMaybe<Array<Scalars['String']['input']>>;
+  collections?: InputMaybe<Array<MediaCollectionFilter>>;
   dateRange?: InputMaybe<ApplicationDateRangeInput>;
   general?: InputMaybe<MediaGeneralFilter>;
 };
@@ -2712,14 +2722,12 @@ export type UserMetadataGroupDetails = {
 
 export type UserMetadataGroupsListInput = {
   filter?: InputMaybe<MediaFilter>;
-  invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<PersonSortInput>;
 };
 
 export type UserMetadataListInput = {
   filter?: InputMaybe<MediaFilter>;
-  invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   lot?: InputMaybe<MediaLot>;
   search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<MediaSortInput>;
@@ -2770,7 +2778,6 @@ export type UserOthersFeaturesEnabledPreferencesInput = {
 
 export type UserPeopleListInput = {
   filter?: InputMaybe<MediaFilter>;
-  invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<PersonSortInput>;
 };
diff --git a/libs/generated/src/graphql/backend/types.generated.ts b/libs/generated/src/graphql/backend/types.generated.ts
index 156a27ec6d..5860a21390 100644
--- a/libs/generated/src/graphql/backend/types.generated.ts
+++ b/libs/generated/src/graphql/backend/types.generated.ts
@@ -1033,8 +1033,18 @@ export type MediaCollectionContentsResults = {
   items: Array<EntityWithLot>;
 };
 
+export type MediaCollectionFilter = {
+  collectionId: Scalars['String']['input'];
+  presence: MediaCollectionPresenceFilter;
+};
+
+export enum MediaCollectionPresenceFilter {
+  NotPresentIn = 'NOT_PRESENT_IN',
+  PresentIn = 'PRESENT_IN'
+}
+
 export type MediaFilter = {
-  collections?: InputMaybe<Array<Scalars['String']['input']>>;
+  collections?: InputMaybe<Array<MediaCollectionFilter>>;
   dateRange?: InputMaybe<ApplicationDateRangeInput>;
   general?: InputMaybe<MediaGeneralFilter>;
 };
@@ -2802,14 +2812,12 @@ export type UserMetadataGroupDetails = {
 
 export type UserMetadataGroupsListInput = {
   filter?: InputMaybe<MediaFilter>;
-  invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<PersonSortInput>;
 };
 
 export type UserMetadataListInput = {
   filter?: InputMaybe<MediaFilter>;
-  invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   lot?: InputMaybe<MediaLot>;
   search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<MediaSortInput>;
@@ -2863,7 +2871,6 @@ export type UserOthersFeaturesEnabledPreferencesInput = {
 
 export type UserPeopleListInput = {
   filter?: InputMaybe<MediaFilter>;
-  invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<PersonSortInput>;
 };


Test patch: