Repository: IgnisDa/ryot (language: rust)

Issue:
Bulk add to collection
Allow adding entities in list pages to a collection.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3fbd282d27..fedc440753 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -108,8 +108,8 @@ jobs:
       - name: Set up Node.js and caching
         uses: actions/setup-node@v4
         with:
-          node-version: '20'
-          cache: 'yarn'
+          node-version: "20"
+          cache: "yarn"
       - name: Build emails
         run: moon run transactional:build
       - name: Upload templates artifact
@@ -256,29 +256,13 @@ jobs:
       - name: Set up Node.js and caching
         uses: actions/setup-node@v4
         with:
-          node-version: '20'
-          cache: 'yarn'
+          node-version: "20"
+          cache: "yarn"
       - name: Build plugin
         run: moon run kodi:build
       - name: Upload plugin to releases
         run: gh release upload --clobber ${{ github.ref_name }} "tmp/script.ryot.zip"
 
-  deploy-demo-instance:
-    needs:
-      - pre-workflow-checks
-      - build-docker
-    if: needs.pre-workflow-checks.outputs.should-release == 'true'
-    runs-on: ubuntu-latest
-
-    steps:
-      - uses: actions/checkout@v4
-      - name: Set up CLI
-        uses: superfly/flyctl-actions/setup-flyctl@master
-      - name: Deploy
-        run: flyctl deploy --remote-only --detach --config ci/fly.toml
-        env:
-          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
-
   deploy-docs:
     needs:
       - pre-workflow-checks
diff --git a/Cargo.lock b/Cargo.lock
index 6f6db7d11d..54ac044e7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5331,9 +5331,9 @@ dependencies = [
 
 [[package]]
 name = "sea-orm"
-version = "1.0.0"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9d4ec1cdd8bdd3553d3c946079f58efa33fedc477f32603652652abcef96fe6"
+checksum = "ea1fee0cf8528dbe6eda29d5798afc522a63b75e44c5b15721e6e64af9c7cc4b"
 dependencies = [
  "async-stream",
  "async-trait",
@@ -5358,9 +5358,9 @@ dependencies = [
 
 [[package]]
 name = "sea-orm-cli"
-version = "1.0.0"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d525eee597817631f800857b0c2fe8645ec9c65b4304176a44bf14b93366009e"
+checksum = "5f0b8869c75cf3fbb1bd860abb025033cd2e514c5f4fa43e792697cb1fe6c882"
 dependencies = [
  "chrono",
  "clap",
@@ -5375,9 +5375,9 @@ dependencies = [
 
 [[package]]
 name = "sea-orm-macros"
-version = "1.0.0"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f363ead48b625a6f8f905322a820464f728fa4fe4f1c222bed5234ccf8fb8555"
+checksum = "8737b566799ed0444f278d13c300c4c6f1a91782f60ff5825a591852d5502030"
 dependencies = [
  "heck 0.4.1",
  "proc-macro2",
@@ -5389,9 +5389,9 @@ dependencies = [
 
 [[package]]
 name = "sea-orm-migration"
-version = "1.0.0"
+version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5df62369752f91b9295f8d71c146d84f818301a9ae7295106ebe8bbaa989a072"
+checksum = "216643749e26ce27ab6c51d3475f2692981d4a902d34455bcd322f412900df5c"
 dependencies = [
  "async-trait",
  "clap",
@@ -5406,9 +5406,9 @@ dependencies = [
 
 [[package]]
 name = "sea-query"
-version = "0.31.0"
+version = "0.31.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e5073b2cfed767511a57d18115f3b3d8bcb5690bf8c89518caec6cb22c0cd74"
+checksum = "b4fd043b8117af233e221f73e3ea8dfbc8e8c3c928017c474296db45c649105c"
 dependencies = [
  "chrono",
  "educe 0.5.11",
diff --git a/Cargo.toml b/Cargo.toml
index 40d8c66bf3..4ffb739589 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -90,7 +90,7 @@ schematic = { version = "=0.17.4", features = [
     "validate",
     "yaml",
 ], default-features = false }
-sea-orm = { version = "=1.0.0", features = [
+sea-orm = { version = "=1.0.1", features = [
     "debug-print",
     "postgres-array",
     "macros",
@@ -101,8 +101,8 @@ sea-orm = { version = "=1.0.0", features = [
     "with-rust_decimal",
     "with-uuid",
 ], default-features = false }
-sea-orm-migration = "=1.0.0"
-sea-query = "=0.31.0"
+sea-orm-migration = "=1.0.1"
+sea-query = "=0.31.1"
 serde = { version = "=1.0.209", features = ["derive"] }
 serde_json = "=1.0.127"
 serde_with = { version = "=3.9.0", features = ["chrono_0_4"] }
diff --git a/apps/frontend/app/lib/state/collection.ts b/apps/frontend/app/lib/state/collection.ts
new file mode 100644
index 0000000000..2d935db664
--- /dev/null
+++ b/apps/frontend/app/lib/state/collection.ts
@@ -0,0 +1,145 @@
+import { notifications } from "@mantine/notifications";
+import { useLocation, useNavigate } from "@remix-run/react";
+import {
+	CollectionContentsDocument,
+	EntityLot,
+	MediaLot,
+	MetadataGroupsListDocument,
+	MetadataListDocument,
+	PeopleListDocument,
+} from "@ryot/generated/graphql/backend/graphql";
+import { isEqual } from "@ryot/ts-utils";
+import { produce } from "immer";
+import { atom, useAtom } from "jotai";
+import { $path } from "remix-routes";
+import { match } from "ts-pattern";
+import { clientGqlService } from "../generals";
+
+type Entity = { entityId: string; entityLot: EntityLot };
+
+type Collection = { id: string; name: string; creatorUserId: string };
+
+type Action = "remove" | "add";
+
+type BulkEditingCollectionData = {
+	action: Action;
+	locationStartedFrom: string;
+	collection: Collection;
+	entities: Array<Entity>;
+	isLoading: boolean;
+};
+
+const bulkEditingCollectionAtom = atom<BulkEditingCollectionData | null>(null);
+
+export const useBulkEditCollection = () => {
+	const [bec, setBec] = useAtom(bulkEditingCollectionAtom);
+	const location = useLocation();
+	const navigate = useNavigate();
+
+	const findIndex = (toFind: Entity) =>
+		(bec?.entities || []).findIndex((inHere) => isEqual(inHere, toFind));
+
+	const start = (collection: Collection, action: Action) => {
+		setBec({
+			action,
+			collection,
+			entities: [],
+			isLoading: false,
+			locationStartedFrom: location.pathname,
+		});
+	};
+
+	return {
+		start,
+		isAdded: (entity: Entity) => findIndex(entity) !== -1,
+		state: bec
+			? {
+					data: bec,
+					stop: (showNotification?: boolean) => {
+						if (showNotification)
+							notifications.show({
+								title: "Success",
+								message:
+									bec.action === "remove"
+										? "Items will be removed from the collection"
+										: "Items will be added to the collection",
+								color: "green",
+							});
+						setBec(null);
+						navigate(bec.locationStartedFrom);
+					},
+					add: (toAdd: Entity) => {
+						if (findIndex(toAdd) !== -1) return;
+						setBec(
+							produce(bec, (draft) => {
+								draft.entities.push(toAdd);
+							}),
+						);
+					},
+					bulkAdd: async () => {
+						setBec({ ...bec, isLoading: true });
+						const take = Number.MAX_SAFE_INTEGER;
+						const entities = await match(bec.action)
+							.with("remove", () =>
+								clientGqlService
+									.request(CollectionContentsDocument, {
+										input: { take, collectionId: bec.collection.id },
+									})
+									.then((r) => r.collectionContents.results.items),
+							)
+							.with("add", () => {
+								const lot = Object.values(MediaLot).find((ml) =>
+									location.pathname.includes(ml),
+								);
+								if (lot)
+									return clientGqlService
+										.request(MetadataListDocument, { input: { lot, take } })
+										.then((r) =>
+											r.metadataList.items.map((m) => ({
+												entityId: m,
+												entityLot: EntityLot.Metadata,
+											})),
+										);
+								if (
+									$path("/media/people/:action", { action: "list" }).includes(
+										location.pathname,
+									)
+								)
+									return clientGqlService
+										.request(PeopleListDocument, { input: { take } })
+										.then((r) =>
+											r.peopleList.items.map((p) => ({
+												entityId: p,
+												entityLot: EntityLot.Person,
+											})),
+										);
+								if (
+									$path("/media/groups/:action", { action: "list" }).includes(
+										location.pathname,
+									)
+								)
+									return clientGqlService
+										.request(MetadataGroupsListDocument, { input: { take } })
+										.then((r) =>
+											r.metadataGroupsList.items.map((p) => ({
+												entityId: p,
+												entityLot: EntityLot.MetadataGroup,
+											})),
+										);
+								return [];
+							})
+							.exhaustive();
+						setBec({ ...bec, isLoading: false, entities });
+					},
+					remove: (toRemove: Entity) => {
+						setBec(
+							produce(bec, (draft) => {
+								draft.entities.splice(findIndex(toRemove), 1);
+							}),
+						);
+					},
+					stopLoading: () => setBec({ ...bec, isLoading: false }),
+				}
+			: (false as const),
+	};
+};
diff --git a/apps/frontend/app/lib/state/fitness.ts b/apps/frontend/app/lib/state/fitness.ts
index d641548df8..607ce4f4f2 100644
--- a/apps/frontend/app/lib/state/fitness.ts
+++ b/apps/frontend/app/lib/state/fitness.ts
@@ -159,12 +159,13 @@ type TWorkoutDetails = WorkoutDetailsQuery["workoutDetails"];
 export const convertHistorySetToCurrentSet = (
 	s: Pick<
 		TWorkoutDetails["details"]["information"]["exercises"][number]["sets"][number],
-		"statistic" | "lot"
+		"statistic" | "lot" | "note"
 	>,
 	confirmedAt?: string | null,
 ) =>
 	({
 		lot: s.lot,
+		note: s.note,
 		statistic: s.statistic,
 		confirmedAt: confirmedAt ?? null,
 	}) satisfies ExerciseSet;
diff --git a/apps/frontend/app/routes/_dashboard.collections.$id._index.tsx b/apps/frontend/app/routes/_dashboard.collections.$id._index.tsx
index b8080492f6..258388564b 100644
--- a/apps/frontend/app/routes/_dashboard.collections.$id._index.tsx
+++ b/apps/frontend/app/routes/_dashboard.collections.$id._index.tsx
@@ -1,6 +1,5 @@
 import {
 	ActionIcon,
-	Affix,
 	Box,
 	Button,
 	Center,
@@ -8,21 +7,19 @@ import {
 	Flex,
 	Group,
 	Pagination,
-	Paper,
 	Select,
 	SimpleGrid,
 	Stack,
 	Tabs,
 	Text,
 	Title,
-	rem,
 } from "@mantine/core";
-import { useDisclosure, useListState } from "@mantine/hooks";
-import { unstable_defineAction, unstable_defineLoader } from "@remix-run/node";
+import { useDisclosure } from "@mantine/hooks";
+import { unstable_defineLoader } from "@remix-run/node";
 import {
-	Form,
 	type MetaArgs_SingleFetch,
 	useLoaderData,
+	useNavigate,
 } from "@remix-run/react";
 import {
 	CollectionContentsDocument,
@@ -30,9 +27,8 @@ import {
 	EntityLot,
 	GraphqlSortOrder,
 	MediaLot,
-	RemoveEntityFromCollectionDocument,
 } from "@ryot/generated/graphql/backend/graphql";
-import { processSubmission, startCase } from "@ryot/ts-utils";
+import { startCase } from "@ryot/ts-utils";
 import {
 	IconBucketDroplet,
 	IconFilter,
@@ -42,9 +38,8 @@ import {
 	IconTrashFilled,
 	IconUser,
 } from "@tabler/icons-react";
-import { Fragment, useState } from "react";
-import { namedAction } from "remix-utils/named-action";
-import { withQuery } from "ufo";
+import { useState } from "react";
+import { $path } from "remix-routes";
 import { z } from "zod";
 import { zx } from "zodix";
 import {
@@ -54,20 +49,14 @@ import {
 	FiltersModal,
 	ReviewItemDisplay,
 } from "~/components/common";
-import {
-	clientGqlService,
-	dayjsLib,
-	pageQueryParam,
-	queryClient,
-	queryFactory,
-} from "~/lib/generals";
+import { dayjsLib, pageQueryParam } from "~/lib/generals";
 import { useAppSearchParam, useUserPreferences } from "~/lib/hooks";
+import { useBulkEditCollection } from "~/lib/state/collection";
 import { useReviewEntity } from "~/lib/state/media";
 import {
 	getEnhancedCookieName,
 	redirectToFirstPageIfOnInvalidPage,
 	redirectUsingEnhancedCookieSearchParams,
-	removeCachedUserCollectionsList,
 	serverGqlService,
 } from "~/lib/utilities.server";
 
@@ -105,7 +94,7 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 			input: {
 				collectionId,
 				filter: {
-					entityType: query.entityLot,
+					entityLot: query.entityLot,
 					metadataLot: query.metadataLot,
 				},
 				sort: { by: query.sortBy, order: query.orderBy },
@@ -125,149 +114,32 @@ export const meta = ({ data }: MetaArgs_SingleFetch<typeof loader>) => {
 	return [{ title: `${data?.collectionContents.details.name} | Ryot` }];
 };
 
-export const action = unstable_defineAction(async ({ request }) => {
-	const formData = await request.clone().formData();
-	return namedAction(request, {
-		bulkRemove: async () => {
-			const submission = processSubmission(formData, bulkRemoveSchema);
-			for (const item of submission.items) {
-				await serverGqlService.authenticatedRequest(
-					request,
-					RemoveEntityFromCollectionDocument,
-					{
-						input: {
-							...item,
-							collectionName: submission.collectionName,
-							creatorUserId: submission.creatorUserId,
-						},
-					},
-				);
-			}
-			await removeCachedUserCollectionsList(request);
-			return Response.json({});
-		},
-	});
-});
-
-const bulkRemoveSchema = z.object({
-	collectionName: z.string(),
-	creatorUserId: z.string(),
-	items: z.array(
-		z.object({
-			entityId: z.string(),
-			entityLot: z.nativeEnum(EntityLot),
-		}),
-	),
-});
-
 export default function Page() {
 	const loaderData = useLoaderData<typeof loader>();
 	const userPreferences = useUserPreferences();
+	const navigate = useNavigate();
 	const [tab, setTab] = useState<string | null>(
 		loaderData.query.defaultTab || DEFAULT_TAB,
 	);
-	const [isBulkRemoving, setIsBulkRemoving] = useState(false);
-	const [isSelectAllLoading, setIsSelectAllLoading] = useState(false);
 	const [_e, { setP }] = useAppSearchParam(loaderData.cookieName);
 	const [_r, setEntityToReview] = useReviewEntity();
+	const bulkEditingCollection = useBulkEditCollection();
 	const [
 		filtersModalOpened,
 		{ open: openFiltersModal, close: closeFiltersModal },
 	] = useDisclosure(false);
-	const [bulkRemoveItems, bulkRemoveItemsHandler] = useListState<{
-		entityId: string;
-		entityLot: EntityLot;
-	}>([]);
-
-	const addBulkRemoveItem = (entityId: string, entityLot: EntityLot) => {
-		if (!bulkRemoveItems.includes({ entityId, entityLot }))
-			bulkRemoveItemsHandler.append({ entityId, entityLot });
+	const colDetails = {
+		id: loaderData.collectionId,
+		name: loaderData.collectionContents.details.name,
+		creatorUserId: loaderData.collectionContents.user.id,
 	};
+	const state = bulkEditingCollection.state;
 
 	return (
 		<Container>
-			{isBulkRemoving ? (
-				<Affix position={{ bottom: rem(30) }} w="100%" px="sm">
-					<Form
-						method="POST"
-						reloadDocument
-						action={withQuery(".", { intent: "bulkRemove" })}
-					>
-						<input
-							type="hidden"
-							name="collectionName"
-							defaultValue={loaderData.collectionContents.details.name}
-						/>
-						<input
-							type="hidden"
-							name="creatorUserId"
-							defaultValue={loaderData.collectionContents.user.id}
-						/>
-						{bulkRemoveItems.map((item, index) => (
-							<Fragment key={item.entityId}>
-								<input
-									type="hidden"
-									readOnly
-									name={`items[${index}].entityId`}
-									value={item.entityId}
-								/>
-								<input
-									type="hidden"
-									readOnly
-									name={`items[${index}].entityLot`}
-									value={item.entityLot}
-								/>
-							</Fragment>
-						))}
-						<Paper withBorder shadow="xl" p="md" w={{ md: "40%" }} mx="auto">
-							<Group wrap="nowrap" justify="space-between">
-								<Text>{bulkRemoveItems.length} items selected</Text>
-								<Group wrap="nowrap">
-									<Button
-										color="blue"
-										loading={isSelectAllLoading}
-										onClick={async () => {
-											setIsSelectAllLoading(true);
-											const { collectionContents } =
-												await queryClient.ensureQueryData({
-													queryKey: queryFactory.collections.details(
-														loaderData.collectionId,
-														Number.MAX_SAFE_INTEGER,
-													).queryKey,
-													queryFn: () =>
-														clientGqlService.request(
-															CollectionContentsDocument,
-															{
-																input: {
-																	collectionId: loaderData.collectionId,
-																	take: Number.MAX_SAFE_INTEGER,
-																},
-															},
-														),
-												});
-											for (const lm of collectionContents.results.items)
-												addBulkRemoveItem(lm.entityId, lm.entityLot);
-											setIsSelectAllLoading(false);
-										}}
-									>
-										Select all items
-									</Button>
-									<Button
-										color="red"
-										type="submit"
-										disabled={bulkRemoveItems.length === 0}
-									>
-										Remove
-									</Button>
-								</Group>
-							</Group>
-						</Paper>
-					</Form>
-				</Affix>
-			) : null}
 			<Stack>
 				<Box>
-					<Title>{loaderData.collectionContents.details.name}</Title>{" "}
+					<Title>{loaderData.collectionContents.details.name}</Title>
 					<Text size="sm">
 						{loaderData.collectionContents.results.details.total} items, created
 						by {loaderData.collectionContents.user.name}{" "}
@@ -329,26 +201,20 @@ export default function Page() {
 							{loaderData.collectionContents.results.items.length > 0 ? (
 								<ApplicationGrid>
 									{loaderData.collectionContents.results.items.map((lm) => {
-										const atIndex = bulkRemoveItems.findIndex(
-											(i) => i.entityId === lm.entityId,
-										);
+										const isAdded = bulkEditingCollection.isAdded(lm);
 										return (
 											<DisplayCollectionEntity
 												key={lm.entityId}
 												entityId={lm.entityId}
 												entityLot={lm.entityLot}
 												topRight={
-													isBulkRemoving ? (
+													state && state.data.action === "remove" ? (
 														<ActionIcon
-															variant={
-																atIndex !== -1 ? "filled" : "transparent"
-															}
+															variant={isAdded ? "filled" : "transparent"}
 															color="red"
-															onClick={(e) => {
-																e.preventDefault();
-																if (atIndex === -1)
-																	addBulkRemoveItem(lm.entityId, lm.entityLot);
-																else bulkRemoveItemsHandler.remove(atIndex);
+															onClick={() => {
+																if (isAdded) state.remove(lm);
+																else state.add(lm);
 															}}
 														>
 															<IconTrashFilled size={18} />
@@ -390,10 +256,28 @@ export default function Page() {
 								Post a review
 							</Button>
 							<Button
+								w="100%"
 								variant="outline"
+								onClick={() => {
+									bulkEditingCollection.start(colDetails, "add");
+									navigate(
+										$path("/media/:action/:lot", {
+											action: "list",
+											lot: MediaLot.Movie,
+										}),
+									);
+								}}
+							>
+								Bulk add
+							</Button>
+							<Button
 								w="100%"
+								variant="outline"
+								disabled={
+									loaderData.collectionContents.results.details.total === 0
+								}
 								onClick={() => {
-									setIsBulkRemoving(true);
+									bulkEditingCollection.start(colDetails, "remove");
 									setTab("contents");
 								}}
 							>
diff --git a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
index b1782560b5..af92c65660 100644
--- a/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.$action.$lot.tsx
@@ -39,6 +39,7 @@ import {
 import { changeCase, snakeCase, startCase } from "@ryot/ts-utils";
 import {
 	IconBoxMultiple,
+	IconCheck,
 	IconDotsVertical,
 	IconFilter,
 	IconListCheck,
@@ -73,6 +74,7 @@ import {
 	useUserDetails,
 	useUserPreferences,
 } from "~/lib/hooks";
+import { useBulkEditCollection } from "~/lib/state/collection";
 import {
 	useAddEntityToCollection,
 	useMetadataProgressUpdate,
@@ -230,6 +232,8 @@ export default function Page() {
 		{ open: openFiltersModal, close: closeFiltersModal },
 	] = useDisclosure(false);
 	const navigate = useNavigate();
+	const bulkEditingCollection = useBulkEditCollection();
+	const bulkEditingState = bulkEditingCollection.state;
 
 	const isFilterChanged =
 		loaderData.mediaList?.url.generalFilter !==
@@ -313,13 +317,35 @@ export default function Page() {
 									items found
 								</Box>
 								<ApplicationGrid>
-									{loaderData.mediaList.list.items.map((item) => (
-										<MetadataDisplayItem
-											key={item}
-											metadataId={item}
-											rightLabelHistory
-										/>
-									))}
+									{loaderData.mediaList.list.items.map((item) => {
+										const becItem = {
+											entityId: item,
+											entityLot: EntityLot.Metadata,
+										};
+										const isAdded = bulkEditingCollection.isAdded(becItem);
+										return (
+											<MetadataDisplayItem
+												key={item}
+												metadataId={item}
+												rightLabelHistory
+												topRight={
+													bulkEditingState &&
+													bulkEditingState.data.action === "add" ? (
+														<ActionIcon
+															variant={isAdded ? "filled" : "transparent"}
+															color="green"
+															onClick={() => {
+																if (isAdded) bulkEditingState.remove(becItem);
+																else bulkEditingState.add(becItem);
+															}}
+														>
+															<IconCheck size={18} />
+														</ActionIcon>
+													) : undefined
+												}
+											/>
+										);
+									})}
 								</ApplicationGrid>
 							</>
 						) : (
diff --git a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
index b9110afbf1..516acdb78a 100644
--- a/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.groups.$action.tsx
@@ -22,16 +22,18 @@ import {
 	useNavigate,
 } from "@remix-run/react";
 import {
+	EntityLot,
 	GraphqlSortOrder,
 	MediaLot,
 	MediaSource,
 	MetadataGroupSearchDocument,
 	type MetadataGroupSearchQuery,
 	MetadataGroupsListDocument,
-	PersonSortBy,
+	PersonAndMetadataGroupsSortBy,
 } from "@ryot/generated/graphql/backend/graphql";
 import { changeCase, isString, startCase } from "@ryot/ts-utils";
 import {
+	IconCheck,
 	IconFilter,
 	IconListCheck,
 	IconSearch,
@@ -56,6 +58,7 @@ import {
 } from "~/components/media";
 import { commaDelimitedString, pageQueryParam } from "~/lib/generals";
 import { useAppSearchParam } from "~/lib/hooks";
+import { useBulkEditCollection } from "~/lib/state/collection";
 import {
 	getEnhancedCookieName,
 	redirectToFirstPageIfOnInvalidPage,
@@ -67,7 +70,7 @@ export type SearchParams = {
 };
 
 const defaultFilters = {
-	sortBy: PersonSortBy.MediaItems,
+	sortBy: PersonAndMetadataGroupsSortBy.MediaItems,
 	orderBy: GraphqlSortOrder.Desc,
 };
 
@@ -91,7 +94,9 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 	const [totalResults, list, search] = await match(action)
 		.with(Action.List, async () => {
 			const urlParse = zx.parseQuery(request, {
-				sortBy: z.nativeEnum(PersonSortBy).default(defaultFilters.sortBy),
+				sortBy: z
+					.nativeEnum(PersonAndMetadataGroupsSortBy)
+					.default(defaultFilters.sortBy),
 				orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy),
 				collections: commaDelimitedString,
 				invertCollection: zx.BoolAsString.optional(),
@@ -168,6 +173,8 @@ export default function Page() {
 		filtersModalOpened,
 		{ open: openFiltersModal, close: closeFiltersModal },
 	] = useDisclosure(false);
+	const bulkEditingCollection = useBulkEditCollection();
+	const bulkEditingState = bulkEditingCollection.state;
 
 	return (
 		<Container>
@@ -254,9 +261,34 @@ export default function Page() {
 						</Box>
 						{loaderData.list.list.details.total > 0 ? (
 							<ApplicationGrid>
-								{loaderData.list.list.items.map((gr) => (
-									<MetadataGroupDisplayItem key={gr} metadataGroupId={gr} />
-								))}
+								{loaderData.list.list.items.map((gr) => {
+									const becItem = {
+										entityId: gr,
+										entityLot: EntityLot.MetadataGroup,
+									};
+									const isAdded = bulkEditingCollection.isAdded(becItem);
+									return (
+										<MetadataGroupDisplayItem
+											key={gr}
+											metadataGroupId={gr}
+											topRight={
+												bulkEditingState &&
+												bulkEditingState.data.action === "add" ? (
+													<ActionIcon
+														variant={isAdded ? "filled" : "transparent"}
+														color="green"
+														onClick={() => {
+															if (isAdded) bulkEditingState.remove(becItem);
+															else bulkEditingState.add(becItem);
+														}}
+													>
+														<IconCheck size={18} />
+													</ActionIcon>
+												) : undefined
+											}
+										/>
+									);
+								})}
 							</ApplicationGrid>
 						) : (
 							<Text>No information to display</Text>
@@ -368,7 +400,7 @@ const FiltersModalForm = () => {
 			<Flex gap="xs" align="center">
 				<Select
 					w="100%"
-					data={Object.values(PersonSortBy).map((o) => ({
+					data={Object.values(PersonAndMetadataGroupsSortBy).map((o) => ({
 						value: o.toString(),
 						label: startCase(o.toLowerCase()),
 					}))}
diff --git a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
index 774cc376bc..ab248f265c 100644
--- a/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
+++ b/apps/frontend/app/routes/_dashboard.media.people.$action.tsx
@@ -22,15 +22,17 @@ import {
 	useNavigate,
 } from "@remix-run/react";
 import {
+	EntityLot,
 	GraphqlSortOrder,
 	MediaSource,
 	PeopleListDocument,
 	PeopleSearchDocument,
 	type PeopleSearchQuery,
-	PersonSortBy,
+	PersonAndMetadataGroupsSortBy,
 } from "@ryot/generated/graphql/backend/graphql";
 import { changeCase, startCase } from "@ryot/ts-utils";
 import {
+	IconCheck,
 	IconFilter,
 	IconListCheck,
 	IconSearch,
@@ -51,6 +53,7 @@ import {
 import { BaseMediaDisplayItem, PersonDisplayItem } from "~/components/media";
 import { commaDelimitedString, pageQueryParam } from "~/lib/generals";
 import { useAppSearchParam } from "~/lib/hooks";
+import { useBulkEditCollection } from "~/lib/state/collection";
 import {
 	getEnhancedCookieName,
 	redirectToFirstPageIfOnInvalidPage,
@@ -63,7 +66,7 @@ export type SearchParams = {
 };
 
 const defaultFilters = {
-	sortBy: PersonSortBy.MediaItems,
+	sortBy: PersonAndMetadataGroupsSortBy.MediaItems,
 	orderBy: GraphqlSortOrder.Desc,
 };
 
@@ -93,7 +96,9 @@ export const loader = unstable_defineLoader(async ({ request, params }) => {
 	const [totalResults, peopleList, peopleSearch] = await match(action)
 		.with(Action.List, async () => {
 			const urlParse = zx.parseQuery(request, {
-				sortBy: z.nativeEnum(PersonSortBy).default(defaultFilters.sortBy),
+				sortBy: z
+					.nativeEnum(PersonAndMetadataGroupsSortBy)
+					.default(defaultFilters.sortBy),
 				orderBy: z.nativeEnum(GraphqlSortOrder).default(defaultFilters.orderBy),
 				collections: commaDelimitedString,
 				invertCollection: zx.BoolAsString.optional(),
@@ -171,6 +176,8 @@ export default function Page() {
 		filtersModalOpened,
 		{ open: openFiltersModal, close: closeFiltersModal },
 	] = useDisclosure(false);
+	const bulkEditingCollection = useBulkEditCollection();
+	const bulkEditingState = bulkEditingCollection.state;
 
 	return (
 		<Container>
@@ -274,9 +281,34 @@ export default function Page() {
 						</Box>
 						{loaderData.peopleList.list.details.total > 0 ? (
 							<ApplicationGrid>
-								{loaderData.peopleList.list.items.map((person) => (
-									<PersonDisplayItem key={person} personId={person} />
-								))}
+								{loaderData.peopleList.list.items.map((person) => {
+									const becItem = {
+										entityId: person,
+										entityLot: EntityLot.Person,
+									};
+									const isAdded = bulkEditingCollection.isAdded(becItem);
+									return (
+										<PersonDisplayItem
+											key={person}
+											personId={person}
+											topRight={
+												bulkEditingState &&
+												bulkEditingState.data.action === "add" ? (
+													<ActionIcon
+														variant={isAdded ? "filled" : "transparent"}
+														color="green"
+														onClick={() => {
+															if (isAdded) bulkEditingState.remove(becItem);
+															else bulkEditingState.add(becItem);
+														}}
+													>
+														<IconCheck size={18} />
+													</ActionIcon>
+												) : undefined
+											}
+										/>
+									);
+								})}
 							</ApplicationGrid>
 						) : (
 							<Text>No information to display</Text>
@@ -390,7 +422,7 @@ const FiltersModalForm = () => {
 			<Flex gap="xs" align="center">
 				<Select
 					w="100%"
-					data={Object.values(PersonSortBy).map((o) => ({
+					data={Object.values(PersonAndMetadataGroupsSortBy).map((o) => ({
 						value: o.toString(),
 						label: startCase(o.toLowerCase()),
 					}))}
diff --git a/apps/frontend/app/routes/_dashboard.tsx b/apps/frontend/app/routes/_dashboard.tsx
index 670a558653..1f52093a49 100644
--- a/apps/frontend/app/routes/_dashboard.tsx
+++ b/apps/frontend/app/routes/_dashboard.tsx
@@ -21,6 +21,7 @@ import {
 	Loader,
 	Modal,
 	NumberInput,
+	Paper,
 	Rating,
 	ScrollArea,
 	SegmentedControl,
@@ -80,6 +81,7 @@ import {
 	IconBook,
 	IconBrandPagekit,
 	IconCalendar,
+	IconCancel,
 	IconChevronLeft,
 	IconChevronRight,
 	IconClock,
@@ -142,6 +144,7 @@ import {
 } from "~/lib/utilities.server";
 import { colorSchemeCookie } from "~/lib/utilities.server";
 import "@mantine/dates/styles.css";
+import { useBulkEditCollection } from "~/lib/state/collection";
 import classes from "~/styles/dashboard.module.css";
 
 const discordLink = "https://discord.gg/D9XTg2a7R8";
@@ -166,10 +169,7 @@ export const loader = unstable_defineLoader(async ({ request }) => {
 			})
 			?.filter((f) => f.enabled)
 			.map((f) => {
-				return {
-					label: changeCase(f.name.toString()),
-					href: undefined,
-				};
+				return { label: f.name, href: undefined };
 			}) || []),
 		userPreferences.featuresEnabled.media.groups
 			? {
@@ -193,12 +193,12 @@ export const loader = unstable_defineLoader(async ({ request }) => {
 		.map((link, _index) =>
 			link
 				? {
-						label: link.label,
+						label: changeCase(link.label),
 						link: link.href
 							? link.href
 							: $path("/media/:action/:lot", {
 									action: "list",
-									lot: link.label.toLowerCase(),
+									lot: link.label,
 								}),
 					}
 				: undefined,
@@ -389,11 +389,27 @@ export default function Layout() {
 	const [measurementsDrawerOpen, setMeasurementsDrawerOpen] =
 		useMeasurementsDrawerOpen();
 	const closeMeasurementsDrawer = () => setMeasurementsDrawerOpen(false);
+	const bulkEditingCollection = useBulkEditCollection();
+	const bulkEditingCollectionState = bulkEditingCollection.state;
+	const shouldShowBulkEditingAffix =
+		bulkEditingCollectionState &&
+		(bulkEditingCollectionState.data.action === "remove"
+			? location.pathname ===
+				$path("/collections/:id", {
+					id: bulkEditingCollectionState.data.collection.id,
+				})
+			: [
+					...Object.values(MediaLot).map((ml) =>
+						$path("/media/:action/:lot", { action: "list", lot: ml }),
+					),
+					$path("/media/people/:action", { action: "list" }),
+					$path("/media/groups/:action", { action: "list" }),
+				].includes(location.pathname));
 
 	return (
 		<>
 			{loaderData.workoutInProgress &&
-			Object.values(FitnessAction)
+			!Object.values(FitnessAction)
 				.map((action) => $path("/fitness/:action", { action }))
 				.includes(location.pathname) ? (
 				<Tooltip label="You have an active workout" position="left">
@@ -427,6 +443,90 @@ export default function Layout() {
 					</Affix>
 				</Tooltip>
 			) : null}
+			{shouldShowBulkEditingAffix ? (
+				<Affix position={{ bottom: rem(30) }} w="100%" px="sm">
+					<Form
+						method="POST"
+						onSubmit={(e) => {
+							submit(e);
+							bulkEditingCollectionState.stop(true);
+						}}
+						action={$path("/actions", { intent: "bulkCollectionAction" })}
+					>
+						<input
+							type="hidden"
+							name="action"
+							defaultValue={bulkEditingCollectionState.data.action}
+						/>
+						<input
+							type="hidden"
+							name="collectionName"
+							defaultValue={bulkEditingCollectionState.data.collection.name}
+						/>
+						<input
+							type="hidden"
+							name="creatorUserId"
+							defaultValue={
+								bulkEditingCollectionState.data.collection.creatorUserId
+							}
+						/>
+						{bulkEditingCollectionState.data.entities.map((item, index) => (
+							<Fragment key={JSON.stringify(item)}>
+								<input
+									readOnly
+									type="hidden"
+									value={item.entityId}
+									name={`items[${index}].entityId`}
+								/>
+								<input
+									readOnly
+									type="hidden"
+									value={item.entityLot}
+									name={`items[${index}].entityLot`}
+								/>
+							</Fragment>
+						))}
+						<Paper withBorder shadow="xl" p="md" w={{ md: "40%" }} mx="auto">
+							<Group wrap="nowrap" justify="space-between">
+								<Text fz={{ base: "xs", md: "md" }}>
+									{bulkEditingCollectionState.data.entities.length} items
+									selected
+								</Text>
+								<Group wrap="nowrap">
+									<ActionIcon
+										size="md"
+										onClick={() => bulkEditingCollectionState.stop()}
+									>
+										<IconCancel />
+									</ActionIcon>
+									<Button
+										size="xs"
+										color="blue"
+										loading={bulkEditingCollectionState.data.isLoading}
+										onClick={() => bulkEditingCollectionState.bulkAdd()}
+									>
+										Select all items
+									</Button>
+									<Button
+										size="xs"
+										color={
+											bulkEditingCollectionState.data.action === "remove"
+												? "red"
+												: "green"
+										}
+										type="submit"
+										disabled={
+											bulkEditingCollectionState.data.entities.length === 0
+										}
+									>
+										{changeCase(bulkEditingCollectionState.data.action)}
+									</Button>
+								</Group>
+							</Group>
+						</Paper>
+					</Form>
+				</Affix>
+			) : null}
 			<Modal
 				onClose={closeMetadataProgressUpdateModal}
 				opened={metadataToUpdate !== null}
diff --git a/apps/frontend/app/routes/actions.tsx b/apps/frontend/app/routes/actions.tsx
index be0a4b0232..1f83ad0720 100644
--- a/apps/frontend/app/routes/actions.tsx
+++ b/apps/frontend/app/routes/actions.tsx
@@ -49,6 +49,9 @@ import {
 	serverGqlService,
 } from "~/lib/utilities.server";
 
+const sleepForHalfSecond = async (request: Request) =>
+	await wait(500, { signal: request.signal });
+
 export const loader = async () => redirect($path("/"));
 
 export const action = unstable_defineAction(async ({ request }) => {
@@ -434,6 +437,25 @@ export const action = unstable_defineAction(async ({ request }) => {
 				}),
 			);
 		})
+		.with("bulkCollectionAction", async () => {
+			const submission = processSubmission(formData, bulkCollectionAction);
+			for (const item of submission.items) {
+				await serverGqlService.authenticatedRequest(
+					request,
+					submission.action === "remove"
+						? RemoveEntityFromCollectionDocument
+						: AddEntityToCollectionDocument,
+					{
+						input: {
+							...item,
+							collectionName: submission.collectionName,
+							creatorUserId: submission.creatorUserId,
+						},
+					},
+				);
+			}
+			await removeCachedUserCollectionsList(request);
+		})
 		.run();
 	if (redirectTo) {
 		headers.append("Location", redirectTo.toString());
@@ -516,5 +538,14 @@ const bulkUpdateSchema = z
 	.merge(MetadataSpecificsSchema)
 	.merge(MetadataIdSchema);
 
-const sleepForHalfSecond = async (request: Request) =>
-	await wait(500, { signal: request.signal });
+const bulkCollectionAction = z.object({
+	action: z.enum(["remove", "add"]),
+	collectionName: z.string(),
+	creatorUserId: z.string(),
+	items: z.array(
+		z.object({
+			entityId: z.string(),
+			entityLot: z.nativeEnum(EntityLot),
+		}),
+	),
+});
diff --git a/crates/models/media/src/lib.rs b/crates/models/media/src/lib.rs
index 415f573c46..bd5b918cdc 100644
--- a/crates/models/media/src/lib.rs
+++ b/crates/models/media/src/lib.rs
@@ -1252,7 +1252,7 @@ pub enum CollectionContentsSortBy {
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone, Default)]
 pub struct CollectionContentsFilter {
-    pub entity_type: Option<EntityLot>,
+    pub entity_lot: Option<EntityLot>,
     pub metadata_lot: Option<MediaLot>,
 }
 
@@ -1385,7 +1385,7 @@ pub enum MediaSortBy {
 }
 
 #[derive(Debug, Serialize, Deserialize, Enum, Clone, PartialEq, Eq, Copy, Default)]
-pub enum PersonSortBy {
+pub enum PersonAndMetadataGroupsSortBy {
     #[default]
     Name,
     MediaItems,
@@ -1393,7 +1393,7 @@ pub enum PersonSortBy {
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone, Default)]
 #[graphql(concrete(name = "MediaSortInput", params(MediaSortBy)))]
-#[graphql(concrete(name = "PersonSortInput", params(PersonSortBy)))]
+#[graphql(concrete(name = "PersonSortInput", params(PersonAndMetadataGroupsSortBy)))]
 #[graphql(concrete(name = "CollectionContentsSortInput", params(CollectionContentsSortBy)))]
 pub struct SortInput<T: InputType + Default> {
     #[graphql(default)]
@@ -1420,27 +1420,30 @@ pub struct MediaFilter {
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
 pub struct MetadataListInput {
-    pub search: SearchInput,
+    pub take: Option<u64>,
     pub lot: Option<MediaLot>,
     pub filter: Option<MediaFilter>,
+    pub search: Option<SearchInput>,
     pub sort: Option<SortInput<MediaSortBy>>,
     pub invert_collection: Option<bool>,
 }
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
 pub struct PeopleListInput {
-    pub search: SearchInput,
-    pub sort: Option<SortInput<PersonSortBy>>,
+    pub take: Option<u64>,
+    pub search: Option<SearchInput>,
     pub filter: Option<MediaFilter>,
     pub invert_collection: Option<bool>,
+    pub sort: Option<SortInput<PersonAndMetadataGroupsSortBy>>,
 }
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
 pub struct MetadataGroupsListInput {
-    pub search: SearchInput,
-    pub sort: Option<SortInput<PersonSortBy>>,
+    pub take: Option<u64>,
+    pub search: Option<SearchInput>,
     pub filter: Option<MediaFilter>,
     pub invert_collection: Option<bool>,
+    pub sort: Option<SortInput<PersonAndMetadataGroupsSortBy>>,
 }
 
 #[derive(Debug, Serialize, Deserialize, InputObject, Clone)]
diff --git a/crates/services/collection/src/lib.rs b/crates/services/collection/src/lib.rs
index 126771d42e..68a4e251e3 100644
--- a/crates/services/collection/src/lib.rs
+++ b/crates/services/collection/src/lib.rs
@@ -33,7 +33,7 @@ use sea_orm::{
     ModelTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait,
 };
 use sea_query::{
-    extension::postgres::PgExpr, Alias, Condition, Expr, Func, Iden, Query, SimpleExpr, Write,
+    extension::postgres::PgExpr, Alias, Condition, Expr, Func, PgFunc, Query, SimpleExpr,
 };
 
 pub struct CollectionService {
@@ -62,30 +62,19 @@ impl CollectionService {
         user_id: &String,
         name: Option<String>,
     ) -> Result<Vec<CollectionItem>> {
-        // TODO: Replace when https://github.com/SeaQL/sea-query/pull/787 is merged
-        struct JsonBuildObject;
-        impl Iden for JsonBuildObject {
-            fn unquoted(&self, s: &mut dyn Write) {
-                write!(s, "JSON_BUILD_OBJECT").unwrap();
-            }
-        }
-        struct JsonAgg;
-        impl Iden for JsonAgg {
-            fn unquoted(&self, s: &mut dyn Write) {
-                write!(s, "JSON_AGG").unwrap();
-            }
-        }
+        let user_jsonb_build_object = PgFunc::json_build_object(vec![
+            (
+                Expr::val("id"),
+                Expr::col((AliasedUser::Table, AliasedUser::Id)),
+            ),
+            (
+                Expr::val("name"),
+                Expr::col((AliasedUser::Table, AliasedUser::Name)),
+            ),
+        ]);
         let collaborators_subquery = Query::select()
             .from(UserToEntity)
-            .expr(
-                Func::cust(JsonAgg).arg(
-                    Func::cust(JsonBuildObject)
-                        .arg(Expr::val("id"))
-                        .arg(Expr::col((AliasedUser::Table, AliasedUser::Id)))
-                        .arg(Expr::val("name"))
-                        .arg(Expr::col((AliasedUser::Table, AliasedUser::Name))),
-                ),
-            )
+            .expr(PgFunc::json_agg(user_jsonb_build_object.clone()))
             .join(
                 JoinType::InnerJoin,
                 AliasedUser::Table,
@@ -147,16 +136,7 @@ impl CollectionService {
                 "collaborators",
             )
             .column(collection::Column::Description)
-            .column_as(
-                Expr::expr(
-                    Func::cust(JsonBuildObject)
-                        .arg(Expr::val("id"))
-                        .arg(Expr::col((AliasedUser::Table, AliasedUser::Id)))
-                        .arg(Expr::val("name"))
-                        .arg(Expr::col((AliasedUser::Table, AliasedUser::Name))),
-                ),
-                "creator",
-            )
+            .column_as(Expr::expr(user_jsonb_build_object), "creator")
             .order_by_desc(collection::Column::LastUpdatedOn)
             .left_join(User)
             .left_join(UserToEntity)
@@ -226,7 +206,7 @@ impl CollectionService {
                             .add(Expr::col((AliasedMetadata::Table, AliasedMetadata::Lot)).eq(v)),
                     )
                 })
-                .apply_if(filter.entity_type, |query, v| {
+                .apply_if(filter.entity_lot, |query, v| {
                     let f = match v {
                         EntityLot::Metadata => {
                             collection_to_entity::Column::MetadataId.is_not_null()
diff --git a/crates/services/miscellaneous/src/lib.rs b/crates/services/miscellaneous/src/lib.rs
index e38b976eac..47cf1c7239 100644
--- a/crates/services/miscellaneous/src/lib.rs
+++ b/crates/services/miscellaneous/src/lib.rs
@@ -65,13 +65,13 @@ use media_models::{
     MetadataGroupsListInput, MetadataImage, MetadataImageForMediaDetails, MetadataListInput,
     MetadataPartialDetails, MetadataSearchInput, MetadataSearchItemResponse, MetadataVideo,
     MetadataVideoSource, PartialMetadata, PartialMetadataPerson, PartialMetadataWithoutId,
-    PeopleListInput, PeopleSearchInput, PeopleSearchItem, PersonDetailsGroupedByRole,
-    PersonDetailsItemWithCharacter, PersonSortBy, PodcastSpecifics, ProgressUpdateError,
-    ProgressUpdateErrorVariant, ProgressUpdateInput, ProgressUpdateResultUnion,
-    ProviderLanguageInformation, ReviewPostedEvent, SeenAnimeExtraInformation,
-    SeenMangaExtraInformation, SeenPodcastExtraInformation, SeenShowExtraInformation,
-    ShowSpecifics, UpdateSeenItemInput, UserCalendarEventInput, UserMediaNextEntry,
-    UserMetadataDetailsEpisodeProgress, UserMetadataDetailsShowSeasonProgress,
+    PeopleListInput, PeopleSearchInput, PeopleSearchItem, PersonAndMetadataGroupsSortBy,
+    PersonDetailsGroupedByRole, PersonDetailsItemWithCharacter, PodcastSpecifics,
+    ProgressUpdateError, ProgressUpdateErrorVariant, ProgressUpdateInput,
+    ProgressUpdateResultUnion, ProviderLanguageInformation, ReviewPostedEvent,
+    SeenAnimeExtraInformation, SeenMangaExtraInformation, SeenPodcastExtraInformation,
+    SeenShowExtraInformation, ShowSpecifics, UpdateSeenItemInput, UserCalendarEventInput,
+    UserMediaNextEntry, UserMetadataDetailsEpisodeProgress, UserMetadataDetailsShowSeasonProgress,
     UserUpcomingCalendarEventInput,
 };
 use migrations::{
@@ -976,22 +976,25 @@ ORDER BY RANDOM() LIMIT 10;
         let avg_rating_col = "user_average_rating";
         let cloned_user_id_1 = user_id.clone();
         let cloned_user_id_2 = user_id.clone();
-        #[derive(Debug, FromQueryResult)]
-        struct InnerMediaSearchItem {
-            id: String,
-        }
 
         let order_by = input
             .sort
             .clone()
             .map(|a| Order::from(a.order))
             .unwrap_or(Order::Asc);
-
         let review_scale = match preferences.general.review_scale {
             UserReviewScale::OutOfFive => 20,
             UserReviewScale::OutOfHundred | UserReviewScale::ThreePointSmiley => 1,
         };
-        let select = Metadata::find()
+        let take = input.take.unwrap_or(self.config.frontend.page_size as u64);
+        let page: u64 = input
+            .search
+            .clone()
+            .and_then(|s| s.page)
+            .unwrap_or(1)
+            .try_into()
+            .unwrap();
+        let paginator = Metadata::find()
             .select_only()
             .column(metadata::Column::Id)
             .expr_as(
@@ -1030,7 +1033,7 @@ ORDER BY RANDOM() LIMIT 10;
                         )
                     }),
             )
-            .apply_if(input.search.query.clone(), |query, v| {
+            .apply_if(input.search.and_then(|s| s.query), |query, v| {
                 query.filter(
                     Cond::any()
                         .add(Expr::col(metadata::Column::Title).ilike(ilike_sql(&v)))
@@ -1091,27 +1094,26 @@ ORDER BY RANDOM() LIMIT 10;
                     order_by,
                     NullOrdering::Last,
                 ),
-            });
-        let total: i32 = select.clone().count(&self.db).await?.try_into().unwrap();
-
-        let items = select
-            .limit(self.config.frontend.page_size as u64)
-            .offset(((input.search.page.unwrap() - 1) * self.config.frontend.page_size) as u64)
-            .into_model::<InnerMediaSearchItem>()
-            .all(&self.db)
-            .await?
-            .into_iter()
-            .map(|m| m.id)
-            .collect_vec();
-
-        let next_page =
-            if total - ((input.search.page.unwrap()) * self.config.frontend.page_size) > 0 {
-                Some(input.search.page.unwrap() + 1)
-            } else {
-                None
-            };
+            })
+            .into_tuple::<String>()
+            .paginate(&self.db, take);
+        let ItemsAndPagesNumber {
+            number_of_items,
+            number_of_pages,
+        } = paginator.num_items_and_pages().await?;
+        let mut items = vec![];
+        for c in paginator.fetch_page(page - 1).await? {
+            items.push(c);
+        }
         Ok(SearchResults {
-            details: SearchDetails { next_page, total },
+            details: SearchDetails {
+                total: number_of_items.try_into().unwrap(),
+                next_page: if page < number_of_pages {
+                    Some((page + 1).try_into().unwrap())
+                } else {
+                    None
+                },
+            },
             items,
         })
     }
@@ -3722,27 +3724,36 @@ ORDER BY RANDOM() LIMIT 10;
         user_id: String,
         input: MetadataGroupsListInput,
     ) -> Result<SearchResults<String>> {
-        let page: u64 = input.search.page.unwrap_or(1).try_into().unwrap();
+        let page: u64 = input
+            .search
+            .clone()
+            .and_then(|f| f.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,
+                    PersonAndMetadataGroupsSortBy::Name => Expr::col(metadata_group::Column::Title),
+                    PersonAndMetadataGroupsSortBy::MediaItems => media_items_col,
                 },
                 ord.order.into(),
             ),
         };
-        let query = MetadataGroup::find()
+        let take = input
+            .take
+            .unwrap_or(self.config.frontend.page_size.try_into().unwrap());
+        let paginator = MetadataGroup::find()
             .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| {
+            .apply_if(input.search.and_then(|f| f.query), |query, v| {
                 query.filter(
                     Condition::all()
                         .add(Expr::col(metadata_group::Column::Title).ilike(ilike_sql(&v))),
@@ -3760,12 +3771,9 @@ ORDER BY RANDOM() LIMIT 10;
                     )
                 },
             )
-            .order_by(order_by, sort_order);
-        let paginator = query
-            .column(metadata_group::Column::Id)
-            .clone()
+            .order_by(order_by, sort_order)
             .into_tuple::<String>()
-            .paginate(&self.db, self.config.frontend.page_size.try_into().unwrap());
+            .paginate(&self.db, take);
         let ItemsAndPagesNumber {
             number_of_items,
             number_of_pages,
@@ -3792,25 +3800,30 @@ ORDER BY RANDOM() LIMIT 10;
         user_id: String,
         input: PeopleListInput,
     ) -> Result<SearchResults<String>> {
-        #[derive(Debug, FromQueryResult)]
-        struct PartialCreator {
-            id: String,
-        }
-        let page: u64 = input.search.page.unwrap_or(1).try_into().unwrap();
+        let page: u64 = input
+            .search
+            .clone()
+            .and_then(|f| f.page)
+            .unwrap_or(1)
+            .try_into()
+            .unwrap();
         let alias = "media_count";
         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(person::Column::Name),
-                    PersonSortBy::MediaItems => media_items_col,
+                    PersonAndMetadataGroupsSortBy::Name => Expr::col(person::Column::Name),
+                    PersonAndMetadataGroupsSortBy::MediaItems => media_items_col,
                 },
                 ord.order.into(),
             ),
         };
-        let query = Person::find()
-            .apply_if(input.search.query, |query, v| {
+        let take = input
+            .take
+            .unwrap_or(self.config.frontend.page_size.try_into().unwrap());
+        let creators_paginator = Person::find()
+            .apply_if(input.search.clone().and_then(|s| s.query), |query, v| {
                 query.filter(
                     Condition::all().add(Expr::col(person::Column::Name).ilike(ilike_sql(&v))),
                 )
@@ -3839,18 +3852,16 @@ ORDER BY RANDOM() LIMIT 10;
             .inner_join(UserToEntity)
             .group_by(person::Column::Id)
             .group_by(person::Column::Name)
-            .order_by(order_by, sort_order);
-        let creators_paginator = query
-            .clone()
-            .into_model::<PartialCreator>()
-            .paginate(&self.db, self.config.frontend.page_size.try_into().unwrap());
+            .order_by(order_by, sort_order)
+            .into_tuple::<String>()
+            .paginate(&self.db, take);
         let ItemsAndPagesNumber {
             number_of_items,
             number_of_pages,
         } = creators_paginator.num_items_and_pages().await?;
         let mut creators = vec![];
         for cr in creators_paginator.fetch_page(page - 1).await? {
-            creators.push(cr.id);
+            creators.push(cr);
         }
         Ok(SearchResults {
             details: SearchDetails {
diff --git a/crates/services/statistics/src/lib.rs b/crates/services/statistics/src/lib.rs
index 3887c50abf..0657542017 100644
--- a/crates/services/statistics/src/lib.rs
+++ b/crates/services/statistics/src/lib.rs
@@ -29,6 +29,7 @@ impl StatisticsService {
         user_id: &String,
         input: DailyUserActivitiesInput,
     ) -> Result<DailyUserActivitiesResponse> {
+        // TODO: https://github.com/SeaQL/sea-query/pull/825 when merged
         struct DateTrunc;
         impl Iden for DateTrunc {
             fn unquoted(&self, s: &mut dyn Write) {
diff --git a/crates/services/user/src/lib.rs b/crates/services/user/src/lib.rs
index eb279c50b4..47d5ea9fd8 100644
--- a/crates/services/user/src/lib.rs
+++ b/crates/services/user/src/lib.rs
@@ -1,4 +1,4 @@
-use std::{fmt::Write, str::FromStr, sync::Arc};
+use std::{str::FromStr, sync::Arc};
 
 use apalis::prelude::MemoryStorage;
 use application_utils::user_id_from_token;
@@ -44,7 +44,7 @@ use openidconnect::{
 use sea_orm::{
     prelude::Expr,
     sea_query::{extension::postgres::PgExpr, Func},
-    ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, Iden, Iterable,
+    ActiveModelTrait, ActiveValue, ColumnTrait, DatabaseConnection, EntityTrait, Iterable,
     ModelTrait, Order, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, QueryTrait,
 };
 use user_models::{
@@ -83,13 +83,6 @@ impl UserService {
     }
 
     pub async fn user_recommendations(&self, user_id: &String) -> Result<Vec<String>> {
-        // TODO: Replace when https://github.com/SeaQL/sea-query/pull/786 is merged
-        struct Md5;
-        impl Iden for Md5 {
-            fn unquoted(&self, s: &mut dyn Write) {
-                write!(s, "MD5").unwrap();
-            }
-        }
         let preferences = user_preferences_by_id(&self.db, user_id, &self.config).await?;
         let limit = preferences
             .general
@@ -102,13 +95,11 @@ impl UserService {
         let recs = Metadata::find()
             .filter(metadata::Column::IsRecommendation.eq(true))
             .order_by(
-                Expr::expr(
-                    Func::cust(Md5).arg(
-                        Expr::col(metadata::Column::Title)
-                            .concat(Expr::val(user_id))
-                            .concat(Expr::val(current_hour)),
-                    ),
-                ),
+                Expr::expr(Func::md5(
+                    Expr::col(metadata::Column::Title)
+                        .concat(Expr::val(user_id))
+                        .concat(Expr::val(current_hour)),
+                )),
                 Order::Desc,
             )
             .limit(limit)
diff --git a/libs/generated/src/graphql/backend/graphql.ts b/libs/generated/src/graphql/backend/graphql.ts
index fa9cc6ff51..b942f08b2c 100644
--- a/libs/generated/src/graphql/backend/graphql.ts
+++ b/libs/generated/src/graphql/backend/graphql.ts
@@ -144,7 +144,7 @@ export type CollectionContents = {
 };
 
 export type CollectionContentsFilter = {
-  entityType?: InputMaybe<EntityLot>;
+  entityLot?: InputMaybe<EntityLot>;
   metadataLot?: InputMaybe<MediaLot>;
 };
 
@@ -1016,16 +1016,18 @@ export type MetadataGroupSearchResults = {
 export type MetadataGroupsListInput = {
   filter?: InputMaybe<MediaFilter>;
   invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
-  search: SearchInput;
+  search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<PersonSortInput>;
+  take?: InputMaybe<Scalars['Int']['input']>;
 };
 
 export type MetadataListInput = {
   filter?: InputMaybe<MediaFilter>;
   invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
   lot?: InputMaybe<MediaLot>;
-  search: SearchInput;
+  search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<MediaSortInput>;
+  take?: InputMaybe<Scalars['Int']['input']>;
 };
 
 export type MetadataPartialDetails = {
@@ -1462,8 +1464,9 @@ export type PasswordUserInput = {
 export type PeopleListInput = {
   filter?: InputMaybe<MediaFilter>;
   invertCollection?: InputMaybe<Scalars['Boolean']['input']>;
-  search: SearchInput;
+  search?: InputMaybe<SearchInput>;
   sort?: InputMaybe<PersonSortInput>;
+  take?: InputMaybe<Scalars['Int']['input']>;
 };
 
 export type PeopleSearchInput = {
@@ -1501,6 +1504,11 @@ export type Person = {
   website?: Maybe<Scalars['String']['output']>;
 };
 
+export enum PersonAndMetadataGroupsSortBy {
+  MediaItems = 'MEDIA_ITEMS',
+  Name = 'NAME'
+}
+
 export type PersonDetails = {
   contents: Array<PersonDetailsGroupedByRole>;
   details: Person;
@@ -1519,13 +1527,8 @@ export type PersonDetailsItemWithCharacter = {
   mediaId: Scalars['String']['output'];
 };
 
-export enum PersonSortBy {
-  MediaItems = 'MEDIA_ITEMS',
-  Name = 'NAME'
-}
-
 export type PersonSortInput = {
-  by?: PersonSortBy;
+  by?: PersonAndMetadataGroupsSortBy;
   order?: GraphqlSortOrder;
 };
 
diff --git a/libs/transactional/tsconfig.json b/libs/transactional/tsconfig.json
index 7554890679..dab890d286 100644
--- a/libs/transactional/tsconfig.json
+++ b/libs/transactional/tsconfig.json
@@ -1,8 +1,6 @@
 {
 	"extends": "../../tsconfig.options.json",
-	"include": [
-		"**/*"
-	],
+	"include": ["**/*"],
 	"references": [
 		{
 			"path": "../ts-utils"
@@ -10,12 +8,8 @@
 	],
 	"compilerOptions": {
 		"paths": {
-			"@ryot/ts-utils": [
-				"../ts-utils/src/index.ts"
-			],
-			"@ryot/ts-utils/*": [
-				"../ts-utils/src/*"
-			]
+			"@ryot/ts-utils": ["../ts-utils/src/index.ts"],
+			"@ryot/ts-utils/*": ["../ts-utils/src/*"]
 		},
 		"module": "ESNext",
 		"jsx": "react-jsx",
diff --git a/libs/ts-utils/src/index.ts b/libs/ts-utils/src/index.ts
index 2eb373a353..f4e4d944fc 100644
--- a/libs/ts-utils/src/index.ts
+++ b/libs/ts-utils/src/index.ts
@@ -50,7 +50,7 @@ export const formatDateToNaiveDate = (t: Date) => {
  */
 export const getInitials = (name: string) => {
 	const rgx = new RegExp(/(\p{L}{1})\p{L}+/gu);
-	const initials = [...name.matchAll(rgx)] || [];
+	const initials = [...name.matchAll(rgx)];
 	const actuals = (
 		(initials.shift()?.[1] || "") + (initials.pop()?.[1] || "")
 	).toUpperCase();


Test patch: