diff --git a/apps/backend/src/miscellaneous.rs b/apps/backend/src/miscellaneous.rs index 9df6cafc10..b559417a72 100644 --- a/apps/backend/src/miscellaneous.rs +++ b/apps/backend/src/miscellaneous.rs @@ -120,9 +120,9 @@ use crate::{ UserPreferences, UserReviewScale, }, utils::{ - add_entity_to_collection, associate_user_with_entity, entity_in_collections, - get_current_date, get_user_to_entity_association, ilike_sql, user_by_id, - user_id_from_token, AUTHOR, SHOW_SPECIAL_SEASON_NAMES, TEMP_DIR, VERSION, + add_entity_to_collection, apply_collection_filter, associate_user_with_entity, + entity_in_collections, get_current_date, get_user_to_entity_association, ilike_sql, + user_by_id, user_id_from_token, AUTHOR, SHOW_SPECIAL_SEASON_NAMES, TEMP_DIR, VERSION, }, }; @@ -541,12 +541,23 @@ struct MetadataListInput { lot: Option, filter: Option, sort: Option>, + invert_collection: Option, } #[derive(Debug, Serialize, Deserialize, InputObject, Clone)] struct PeopleListInput { search: SearchInput, sort: Option>, + filter: Option, + invert_collection: Option, +} + +#[derive(Debug, Serialize, Deserialize, InputObject, Clone)] +struct MetadataGroupsListInput { + search: SearchInput, + sort: Option>, + filter: Option, + invert_collection: Option, } #[derive(Debug, Serialize, Deserialize, InputObject, Clone)] @@ -919,7 +930,7 @@ impl MiscellaneousQuery { async fn metadata_groups_list( &self, gql_ctx: &Context<'_>, - input: SearchInput, + input: MetadataGroupsListInput, ) -> Result> { let service = gql_ctx.data_unchecked::>(); let user_id = self.user_id_from_ctx(gql_ctx).await?; @@ -2287,9 +2298,13 @@ impl MiscellaneousService { .apply_if( input.filter.clone().and_then(|f| f.collection), |query, v| { - query - .inner_join(CollectionToEntity) - .filter(collection_to_entity::Column::CollectionId.eq(v)) + apply_collection_filter( + query, + Some(v), + input.invert_collection, + metadata::Column::Id, + collection_to_entity::Column::MetadataId, + ) }, ) .apply_if(input.filter.and_then(|f| f.general), |query, v| match v { @@ -6377,19 +6392,47 @@ impl MiscellaneousService { async fn metadata_groups_list( &self, user_id: String, - input: SearchInput, + input: MetadataGroupsListInput, ) -> Result> { - let page: u64 = input.page.unwrap_or(1).try_into().unwrap(); + let page: u64 = input.search.page.unwrap_or(1).try_into().unwrap(); + let alias = "parts"; + let media_items_col = Expr::col(Alias::new(alias)); + let (order_by, sort_order) = match input.sort { + None => (media_items_col, Order::Desc), + Some(ord) => ( + match ord.by { + PersonSortBy::Name => Expr::col(metadata_group::Column::Title), + PersonSortBy::MediaItems => media_items_col, + }, + ord.order.into(), + ), + }; let query = MetadataGroup::find() - .apply_if(input.query, |query, v| { + .select_only() + .column(metadata_group::Column::Id) + .group_by(metadata_group::Column::Id) + .inner_join(UserToEntity) + .filter(user_to_entity::Column::UserId.eq(&user_id)) + .filter(metadata_group::Column::Id.is_not_null()) + .apply_if(input.search.query, |query, v| { query.filter( Condition::all() .add(Expr::col(metadata_group::Column::Title).ilike(ilike_sql(&v))), ) }) - .filter(user_to_entity::Column::UserId.eq(user_id)) - .inner_join(UserToEntity) - .order_by_asc(metadata_group::Column::Title); + .apply_if( + input.filter.clone().and_then(|f| f.collection), + |query, v| { + apply_collection_filter( + query, + Some(v), + input.invert_collection, + metadata_group::Column::Id, + collection_to_entity::Column::MetadataGroupId, + ) + }, + ) + .order_by(order_by, sort_order); let paginator = query .column(metadata_group::Column::Id) .clone() @@ -6444,6 +6487,18 @@ impl MiscellaneousService { Condition::all().add(Expr::col(person::Column::Name).ilike(ilike_sql(&v))), ) }) + .apply_if( + input.filter.clone().and_then(|f| f.collection), + |query, v| { + apply_collection_filter( + query, + Some(v), + input.invert_collection, + person::Column::Id, + collection_to_entity::Column::PersonId, + ) + }, + ) .column_as( Expr::expr(Func::count(Expr::col(( Alias::new("metadata_to_person"), diff --git a/apps/backend/src/utils.rs b/apps/backend/src/utils.rs index 9562794c79..78b318ef02 100644 --- a/apps/backend/src/utils.rs +++ b/apps/backend/src/utils.rs @@ -22,7 +22,7 @@ use reqwest::{ use rs_utils::PROJECT_NAME; use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, - QueryFilter, + QueryFilter, QuerySelect, QueryTrait, Select, }; use crate::{ @@ -361,6 +361,33 @@ pub async fn add_entity_to_collection( Ok(resp) } +pub fn apply_collection_filter( + query: Select, + collection_id: Option, + invert_collection: Option, + entity_column: C, + id_column: D, +) -> Select +where + E: EntityTrait, + C: ColumnTrait, + D: ColumnTrait, +{ + query.apply_if(collection_id, |query, v| { + let subquery = CollectionToEntity::find() + .select_only() + .column(id_column) + .filter(collection_to_entity::Column::CollectionId.eq(v)) + .filter(id_column.is_not_null()) + .into_query(); + if invert_collection.unwrap_or_default() { + query.filter(entity_column.not_in_subquery(subquery)) + } else { + query.filter(entity_column.in_subquery(subquery)) + } + }) +} + pub fn get_current_date(timezone: &chrono_tz::Tz) -> NaiveDate { Utc::now().with_timezone(timezone).date_naive() } diff --git a/apps/frontend/app/components/media.tsx b/apps/frontend/app/components/media.tsx index df4e262768..a801509de0 100644 --- a/apps/frontend/app/components/media.tsx +++ b/apps/frontend/app/components/media.tsx @@ -737,7 +737,7 @@ export const PersonDisplayItem = (props: { imageUrl={personDetails?.details.displayImages.at(0)} labels={{ left: personDetails - ? `${personDetails.contents.length} items` + ? `${personDetails.contents.reduce((sum, content) => sum + content.items.length, 0)} items` : undefined, right: props.rightLabel, }} diff --git a/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx b/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx index d64c437d5e..c3f772c634 100644 --- a/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx +++ b/apps/frontend/app/routes/_dashboard.fitness.exercises.list.tsx @@ -363,23 +363,21 @@ const FiltersModalForm = () => { onChange={(v) => setP(f, v)} /> ))} - {collections.length > 0 ? ( - ({ + value: c.id.toString(), + label: c.name, + })), + }, + ]} + onChange={(v) => setP("collection", v)} + clearable + /> ); diff --git a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx index b4c78c248f..eb41a04fac 100644 --- a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx +++ b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx @@ -3,6 +3,7 @@ import { Box, Button, Center, + Checkbox, Container, Flex, Group, @@ -135,6 +136,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => { .nativeEnum(MediaGeneralFilter) .default(defaultFilters.mineGeneralFilter), collection: z.string().optional(), + invertCollection: zx.BoolAsString.optional(), }); const { metadataList } = await serverGqlService.authenticatedRequest( request, @@ -148,6 +150,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => { general: urlParse.generalFilter, collection: urlParse.collection, }, + invertCollection: urlParse.invertCollection, }, }, ); @@ -574,8 +577,9 @@ const FiltersModalForm = () => { )} - {collections.length > 0 ? ( + { const data = new FormData(); - const location = withoutHost(window.location.href); data.append("identifier", identifier); data.append("source", source); data.append("lot", lot); - data.append(redirectToQueryParam, location); const resp = await fetch( $path("/actions", { intent: "commitMetadataGroup" }), { method: "POST", body: data }, @@ -279,3 +340,64 @@ const commitGroup = async ( const json = await resp.json(); return json.commitMetadataGroup.id; }; + +const FiltersModalForm = () => { + const loaderData = useLoaderData(); + const collections = useUserCollections(); + const [_, { setP }] = useAppSearchParam(loaderData.cookieName); + + if (!loaderData.list) return null; + + return ( + <> + + ({ + value: c.id.toString(), + label: c.name, + })), + }, + ]} + onChange={(v) => setP("collection", v)} + clearable + searchable + /> + setP("invertCollection", String(e.target.checked))} + /> + + + ); +}; diff --git a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx index 8df07c6366..24b260a95d 100644 --- a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx +++ b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx @@ -40,7 +40,6 @@ import { import { useState } from "react"; import { $path } from "remix-routes"; import { match } from "ts-pattern"; -import { withoutHost } from "ufo"; import { z } from "zod"; import { zx } from "zodix"; import { @@ -49,8 +48,11 @@ import { FiltersModal, } from "~/components/common"; import { BaseMediaDisplayItem, PersonDisplayItem } from "~/components/media"; -import { redirectToQueryParam } from "~/lib/generals"; -import { useAppSearchParam, useCoreDetails } from "~/lib/hooks"; +import { + useAppSearchParam, + useCoreDetails, + useUserCollections, +} from "~/lib/hooks"; import { getEnhancedCookieName, redirectUsingEnhancedCookieSearchParams, @@ -94,6 +96,8 @@ export const loader = unstable_defineLoader(async ({ request, params }) => { const urlParse = zx.parseQuery(request, { sortBy: z.nativeEnum(PersonSortBy).default(defaultFilters.sortBy), orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy), + collection: z.string().optional(), + invertCollection: zx.BoolAsString.optional(), }); const { peopleList } = await serverGqlService.authenticatedRequest( request, @@ -102,6 +106,10 @@ export const loader = unstable_defineLoader(async ({ request, params }) => { input: { search: { page, query }, sort: { by: urlParse.sortBy, order: urlParse.orderBy }, + filter: { + collection: urlParse.collection, + }, + invertCollection: urlParse.invertCollection, }, }, ); @@ -347,13 +355,11 @@ const commitPerson = async ( isAnilistStudio?: boolean, ) => { const data = new FormData(); - const location = withoutHost(window.location.href); data.append("identifier", identifier); data.append("source", source); if (name) data.append("name", name); if (isTmdbCompany) data.append("isTmdbCompany", String(isTmdbCompany)); if (isAnilistStudio) data.append("isAnilistStudio", String(isAnilistStudio)); - data.append(redirectToQueryParam, location); const resp = await fetch($path("/actions", { intent: "commitPerson" }), { method: "POST", body: data, @@ -364,32 +370,61 @@ const commitPerson = async ( const FiltersModalForm = () => { const loaderData = useLoaderData(); + const collections = useUserCollections(); const [_, { setP }] = useAppSearchParam(loaderData.cookieName); + if (!loaderData.peopleList) return null; + return ( - - ({ + value: o.toString(), + label: startCase(o.toLowerCase()), + }))} + defaultValue={loaderData.peopleList.url.sortBy} + onChange={(v) => setP("sortBy", v)} + /> + { + if (loaderData.peopleList?.url.orderBy === GraphqlSortOrder.Asc) + setP("orderBy", GraphqlSortOrder.Desc); + else setP("orderBy", GraphqlSortOrder.Asc); + }} + > + {loaderData.peopleList.url.orderBy === GraphqlSortOrder.Asc ? ( + + ) : ( + + )} + + + +