diff --git a/public/locales/en/settings/customization/general.json b/public/locales/en/settings/customization/general.json
index d258eb1b9..46c79f66e 100644
--- a/public/locales/en/settings/customization/general.json
+++ b/public/locales/en/settings/customization/general.json
@@ -16,6 +16,10 @@
"access": {
"name": "Access",
"description": "Configure who has access to your board"
+ },
+ "search": {
+ "name": "Search",
+ "description": "Customize the search experience on your board"
}
}
}
\ No newline at end of file
diff --git a/public/locales/en/settings/customization/search.json b/public/locales/en/settings/customization/search.json
new file mode 100644
index 000000000..83e7fe275
--- /dev/null
+++ b/public/locales/en/settings/customization/search.json
@@ -0,0 +1,6 @@
+{
+ "mediaIntegrations": {
+ "label": "Search media integrations",
+ "description": "Selected integrations will be available in the search of this board"
+ }
+}
\ No newline at end of file
diff --git a/src/components/Board/Customize/Search/SearchCustomization.tsx b/src/components/Board/Customize/Search/SearchCustomization.tsx
new file mode 100644
index 000000000..38a573fc4
--- /dev/null
+++ b/src/components/Board/Customize/Search/SearchCustomization.tsx
@@ -0,0 +1,105 @@
+import {
+ Avatar,
+ Box,
+ CloseButton,
+ Group,
+ MultiSelect,
+ MultiSelectValueProps,
+ Stack,
+ Text,
+ rem,
+} from '@mantine/core';
+import { useTranslation } from 'next-i18next';
+import { forwardRef } from 'react';
+import { IntegrationType, integrationTypes } from '~/server/db/items';
+import { Integration } from '~/server/db/schema';
+import { RouterOutputs } from '~/utils/api';
+
+import { useBoardCustomizationFormContext } from '../form';
+
+interface IntegrationCustomizationProps {
+ allMediaIntegrations: RouterOutputs['integration']['allMedia'];
+}
+
+export const SearchCustomization = ({ allMediaIntegrations }: IntegrationCustomizationProps) => {
+ const { t } = useTranslation('settings/customization/search');
+ const form = useBoardCustomizationFormContext();
+
+ return (
+
+ ({
+ value: x.id,
+ label: x.name,
+ sort: x.sort,
+ }))}
+ />
+
+ );
+};
+
+interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
+ sort: IntegrationType;
+ label: string;
+}
+
+const IntegrationSelectItem = forwardRef(
+ ({ label, sort, ...others }: ItemProps, ref) => {
+ return (
+
+ );
+ }
+);
+
+const IntegrationSelectValue =
+ (integrations: Integration[]) =>
+ ({
+ value,
+ label,
+ onRemove,
+ classNames,
+ ...others
+ }: MultiSelectValueProps & { value: string }) => {
+ const current = integrations.find((x) => x.id === value);
+ return (
+
+
({
+ display: 'flex',
+ cursor: 'default',
+ alignItems: 'center',
+ backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.white,
+ border: `${rem(1)} solid ${
+ theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[4]
+ }`,
+ paddingLeft: theme.spacing.xs,
+ borderRadius: theme.radius.sm,
+ })}
+ >
+
+
+
+ {label}
+
+
+
+ );
+ };
diff --git a/src/components/layout/header/Search.tsx b/src/components/layout/header/Search.tsx
index 25a598cf9..5a4457bbc 100644
--- a/src/components/layout/header/Search.tsx
+++ b/src/components/layout/header/Search.tsx
@@ -49,10 +49,7 @@ export const Search = ({ isMobile, autoFocus }: SearchProps) => {
)
.filter(
(engine) =>
- engine.sort !== 'movie' ||
- board?.sections.some((section) =>
- section.items.some((x) => x.kind === 'app' && x.integration?.type === engine.value)
- )
+ engine.sort !== 'movie' || board?.mediaIntegrations.some((x) => x.sort === engine.value)
)
.map((engine) => ({
...engine,
@@ -61,6 +58,7 @@ export const Search = ({ isMobile, autoFocus }: SearchProps) => {
query: search,
}),
}));
+
const data = [...apps, ...engines];
return (
diff --git a/src/components/layout/header/Search/MovieModal.tsx b/src/components/layout/header/Search/MovieModal.tsx
index eafd17b0e..d017421e8 100644
--- a/src/components/layout/header/Search/MovieModal.tsx
+++ b/src/components/layout/header/Search/MovieModal.tsx
@@ -131,14 +131,6 @@ const MovieDisplay = ({ movie, type }: MovieDisplayProps) => {
const { t } = useTranslation('modules/common-media-cards');
const [requestModalOpened, requestModal] = useDisclosure(false);
- /*const service = config.apps.find((service) => service.integration.type === type);
- const mediaUrl = movie.mediaInfo?.plexUrl ?? movie.mediaInfo?.mediaUrl;
- const serviceUrl = service?.behaviour.externalUrl ?? service?.url;
- const externalUrl = new URL(
- `${movie.mediaType}/${movie.id}`,
- serviceUrl ?? 'https://www.themoviedb.org'
- );*/
-
return (
diff --git a/src/modules/common/MediaDisplay.tsx b/src/modules/common/MediaDisplay.tsx
index 860fc0c99..9c4e84b41 100644
--- a/src/modules/common/MediaDisplay.tsx
+++ b/src/modules/common/MediaDisplay.tsx
@@ -26,41 +26,6 @@ export interface IMedia {
[key: string]: any;
}
-export function OverseerrMediaDisplay(props: any) {
- const { media }: { media: Result } = props;
- const { config } = useConfigContext();
-
- if (!config) {
- return null;
- }
-
- const service = config.apps.find(
- (service) =>
- service.integration.type === 'overseerr' || service.integration.type === 'jellyseerr'
- );
-
- return (
-
- );
-}
-
export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfigContext();
@@ -192,7 +157,14 @@ export function MediaDisplay({ media }: { media: IMedia }) {
return (
-
+
diff --git a/src/pages/board/[slug]/customize.tsx b/src/pages/board/[slug]/customize.tsx
index 0c8e27836..16a63a123 100644
--- a/src/pages/board/[slug]/customize.tsx
+++ b/src/pages/board/[slug]/customize.tsx
@@ -20,6 +20,7 @@ import {
IconChartCandle,
IconCheck,
IconLock,
+ IconSearch,
IconX,
TablerIconsProps,
} from '@tabler/icons-react';
@@ -30,18 +31,20 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { ReactNode } from 'react';
import { z } from 'zod';
+import type generalSettingsTranslations from '~/../public/locales/en/settings/customization/general.json';
import { AccessCustomization } from '~/components/Board/Customize/Access/AccessCustomization';
import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization';
import { NetworkCustomization } from '~/components/Board/Customize/Network/NetworkCustomization';
import { PageMetadataCustomization } from '~/components/Board/Customize/PageMetadata/PageMetadataCustomization';
+import { SearchCustomization } from '~/components/Board/Customize/Search/SearchCustomization';
import {
BoardCustomizationFormProvider,
useBoardCustomizationForm,
} from '~/components/Board/Customize/form';
import { useBoardLink } from '~/components/layout/Templates/BoardLayout';
import { MainLayout } from '~/components/layout/Templates/MainLayout';
-import { createTrpcServersideHelpers } from '~/server/api/helper';
import { boardRouter } from '~/server/api/routers/board';
+import { integrationRouter } from '~/server/api/routers/integration';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
@@ -54,22 +57,27 @@ const notificationId = 'board-customization-notification';
export default function CustomizationPage({
initialBoard,
+ allMediaIntegrations,
}: InferGetServerSidePropsType) {
const query = useRouter().query as { slug: string };
const utils = api.useContext();
const {
- data: board,
+ data: queryBoard,
isError,
error,
} = api.boards.byNameSimple.useQuery(
{ boardName: query.slug },
{
- initialData: initialBoard,
refetchOnMount: false,
+ refetchOnReconnect: false,
useErrorBoundary: false,
suspense: false,
+ enabled: typeof window !== 'undefined', // Disable on server-side so it is not cached with an old result.
}
);
+
+ const board = queryBoard ?? initialBoard; // Initialdata property is not working because it somehow ignores the enabled property.
+
const { mutateAsync: updateCustomization, isLoading } =
api.boards.updateCustomization.useMutation();
const { i18nZodResolver } = useI18nZodResolver();
@@ -99,6 +107,9 @@ export default function CustomizationPage({
logoSrc: board.logoImageUrl ?? '',
faviconSrc: board.faviconImageUrl ?? '',
},
+ search: {
+ mediaIntegrations: board.mediaIntegrations.map(({ id }) => id),
+ },
},
validate: i18nZodResolver(boardCustomizationSchema),
validateInputOnChange: true,
@@ -250,6 +261,10 @@ export default function CustomizationPage({
+
+
+
+
@@ -264,7 +279,7 @@ export default function CustomizationPage({
}
type SectionTitleProps = {
- type: 'network' | 'pageMetadata' | 'appereance' | 'access';
+ type: keyof (typeof generalSettingsTranslations)['accordeon'];
icon: (props: TablerIconsProps) => ReactNode;
};
@@ -305,20 +320,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return result;
}
- const helpers = await createTrpcServersideHelpers({ req: context.req, res: context.res });
- const caller = boardRouter.createCaller({
+ const boardCaller = boardRouter.createCaller({
session: session,
cookies: context.req.cookies,
headers: context.req.headers,
});
- const board = await caller.byNameSimple({ boardName: routeParams.data.slug });
+ const board = await boardCaller.byNameSimple({ boardName: routeParams.data.slug });
if (!board) {
return {
notFound: true,
};
}
+ const integrationCaller = integrationRouter.createCaller({
+ session: session,
+ cookies: context.req.cookies,
+ headers: context.req.headers,
+ });
+
+ const allMediaIntegrations = await integrationCaller.allMedia();
+
const translations = await getServerSideTranslations(
[
'boards/customize',
@@ -329,6 +351,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
'settings/customization/opacity-selector',
'settings/customization/gridstack',
'settings/customization/access',
+ 'settings/customization/search',
],
context.locale,
context.req,
@@ -341,6 +364,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
secondaryColor: board.secondaryColor,
primaryShade: board.primaryShade,
initialBoard: board,
+ allMediaIntegrations,
...translations,
},
};
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 7cc34e135..63e2cbcc1 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -9,6 +9,7 @@ import { dnsHoleRouter } from './routers/dns-hole/router';
import { dockerRouter } from './routers/docker/router';
import { downloadRouter } from './routers/download';
import { iconRouter } from './routers/icon';
+import { integrationRouter } from './routers/integration';
import { inviteRouter } from './routers/invite/invite-router';
import { layoutsRouter } from './routers/layout/layout.router';
import { mediaRequestsRouter } from './routers/media-request';
@@ -49,6 +50,7 @@ export const rootRouter = createTRPCRouter({
password: passwordRouter,
notebook: notebookRouter,
layouts: layoutsRouter,
+ integration: integrationRouter,
});
// export type definition of API
diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts
index 150af05cf..3f2352b4e 100644
--- a/src/server/api/routers/board.ts
+++ b/src/server/api/routers/board.ts
@@ -9,6 +9,7 @@ import {
Integration,
appStatusCodes,
apps,
+ boardIntegrations,
boards,
items,
layoutItems,
@@ -262,26 +263,59 @@ export const boardRouter = createTRPCRouter({
});
}
- await db
- .update(boards)
- .set({
- allowGuests: input.customization.access.allowGuests,
- isPingEnabled: input.customization.network.pingsEnabled,
- appOpacity: input.customization.appearance.opacity,
- backgroundImageUrl: input.customization.appearance.backgroundSrc,
- backgroundImageAttachment: input.customization.appearance.backgroundImageAttachment,
- backgroundImageSize: input.customization.appearance.backgroundImageSize,
- backgroundImageRepeat: input.customization.appearance.backgroundImageRepeat,
- primaryColor: input.customization.appearance.primaryColor,
- secondaryColor: input.customization.appearance.secondaryColor,
- customCss: input.customization.appearance.customCss,
- pageTitle: input.customization.pageMetadata.pageTitle,
- metaTitle: input.customization.pageMetadata.metaTitle,
- logoImageUrl: input.customization.pageMetadata.logoSrc,
- faviconImageUrl: input.customization.pageMetadata.faviconSrc,
- primaryShade: input.customization.appearance.shade,
- })
- .where(eq(boards.id, dbBoard.id));
+ const dbIntegrations = await db.query.boardIntegrations.findMany({
+ where: eq(boardIntegrations.boardId, dbBoard.id),
+ });
+
+ const inputIntegrations = input.customization.search.mediaIntegrations;
+
+ const newIntegrations = inputIntegrations.filter((id) =>
+ dbIntegrations.every((y) => y.integrationId !== id)
+ );
+
+ const removedIntegrations = dbIntegrations.filter((x) =>
+ inputIntegrations.every((y) => y !== x.integrationId)
+ );
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(boards)
+ .set({
+ allowGuests: input.customization.access.allowGuests,
+ isPingEnabled: input.customization.network.pingsEnabled,
+ appOpacity: input.customization.appearance.opacity,
+ backgroundImageUrl: input.customization.appearance.backgroundSrc,
+ backgroundImageAttachment: input.customization.appearance.backgroundImageAttachment,
+ backgroundImageSize: input.customization.appearance.backgroundImageSize,
+ backgroundImageRepeat: input.customization.appearance.backgroundImageRepeat,
+ primaryColor: input.customization.appearance.primaryColor,
+ secondaryColor: input.customization.appearance.secondaryColor,
+ customCss: input.customization.appearance.customCss,
+ pageTitle: input.customization.pageMetadata.pageTitle,
+ metaTitle: input.customization.pageMetadata.metaTitle,
+ logoImageUrl: input.customization.pageMetadata.logoSrc,
+ faviconImageUrl: input.customization.pageMetadata.faviconSrc,
+ primaryShade: input.customization.appearance.shade,
+ })
+ .where(eq(boards.id, dbBoard.id));
+
+ if (newIntegrations.length > 0) {
+ await tx.insert(boardIntegrations).values(
+ newIntegrations.map((id) => ({
+ boardId: dbBoard.id,
+ integrationId: id,
+ }))
+ );
+ }
+ if (removedIntegrations.length > 0) {
+ await tx.delete(boardIntegrations).where(
+ inArray(
+ boardIntegrations.integrationId,
+ removedIntegrations.map((x) => x.integrationId)
+ )
+ );
+ }
+ });
return dbBoard;
}),
diff --git a/src/server/api/routers/integration.ts b/src/server/api/routers/integration.ts
new file mode 100644
index 000000000..876bc7776
--- /dev/null
+++ b/src/server/api/routers/integration.ts
@@ -0,0 +1,13 @@
+import { inArray } from 'drizzle-orm';
+import { db } from '~/server/db';
+import { integrations } from '~/server/db/schema';
+
+import { adminProcedure, createTRPCRouter } from '../trpc';
+
+export const integrationRouter = createTRPCRouter({
+ allMedia: adminProcedure.query(async () => {
+ return await db.query.integrations.findMany({
+ where: inArray(integrations.sort, ['jellyseerr', 'overseerr']),
+ });
+ }),
+});
diff --git a/src/server/api/routers/overseerr.ts b/src/server/api/routers/overseerr.ts
index 0ed7d20c3..63a7a3d46 100644
--- a/src/server/api/routers/overseerr.ts
+++ b/src/server/api/routers/overseerr.ts
@@ -1,11 +1,14 @@
import { TRPCError } from '@trpc/server';
import axios from 'axios';
import Consola from 'consola';
+import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { MovieResult } from '~/modules/overseerr/Movie';
import { Result } from '~/modules/overseerr/SearchResult';
import { TvShowResult } from '~/modules/overseerr/TvShow';
-import { getIntegrations, getSecret } from '~/server/db/queries/integrations';
+import { db } from '~/server/db';
+import { getSecret } from '~/server/db/queries/integrations';
+import { boardIntegrations, integrationSecrets, integrations } from '~/server/db/schema';
import { getConfig } from '~/tools/config/getConfig';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -20,47 +23,51 @@ export const overseerrRouter = createTRPCRouter({
limit: z.number().default(10),
})
)
- .query(async ({ input, ctx }) => {
- const integrations = await getIntegrations(
- input.boardId,
- [input.integration],
- ctx.session?.user
- );
+ .query(async ({ input }) => {
+ const dbIntegration = await db
+ .select()
+ .from(boardIntegrations)
+ .leftJoin(integrations, eq(integrations.id, boardIntegrations.integrationId))
+ .leftJoin(integrationSecrets, eq(integrationSecrets.integrationId, integrations.id))
+ .where(
+ and(
+ eq(boardIntegrations.boardId, input.boardId),
+ eq(integrations.sort, input.integration)
+ )
+ )
+ .get();
- if (input.query === '' || input.query === undefined || integrations.length === 0) {
+ if (!dbIntegration || !dbIntegration.integration || !dbIntegration.integration_secret) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No integration found',
+ });
+ }
+
+ if (input.query === '' || input.query === undefined) {
return [];
}
- const resultsFromIntegrationApi = await Promise.allSettled>(
- integrations.map(async (integration) => {
- const url = new URL(integration.url);
- return await axios
- .get(`${url.origin}/api/v1/search?query=${input.query}`, {
- headers: {
- // Set X-Api-Key to the value of the API key
- 'X-Api-Key': getSecret(integration, 'apiKey'),
- },
- })
- .then((res) =>
- res.data.results.map((x: Result) => ({ ...x, integrationId: integration.id }))
- )
- .catch((err) => {
- Consola.error(err);
- return [];
- });
+ const integration = {
+ ...dbIntegration.integration,
+ secrets: [dbIntegration.integration_secret!],
+ };
+ const url = new URL(integration.url);
+ const results: Result[] = await axios
+ .get(`${url.origin}/api/v1/search?query=${input.query}`, {
+ headers: {
+ // Set X-Api-Key to the value of the API key
+ 'X-Api-Key': getSecret(integration, 'apiKey'),
+ },
})
- );
-
- const results = resultsFromIntegrationApi
- .filter(
- (x): x is PromiseFulfilledResult<(Result & { integrationId: string })[]> =>
- x.status === 'fulfilled'
- )
- .flatMap((x) => x.value);
+ .then((res) => res.data)
+ .catch((err) => {
+ Consola.error(err);
+ return [];
+ });
return results.slice(0, input.limit).map((result) => ({
id: result.id,
- integrationId: result.integrationId,
imageUrl: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${
result.posterPath ?? result.backdropPath
}`,
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index c73446c7c..200738462 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -179,8 +179,8 @@ export const integrationSecrets = sqliteTable(
export const boardIntegrations = sqliteTable(
'board_integration',
{
- boardId: text('board_id'),
- integrationId: text('integration_id'),
+ boardId: text('board_id').notNull(),
+ integrationId: text('integration_id').notNull(),
},
(boardIntegration) => ({
compoundKey: primaryKey(boardIntegration.boardId, boardIntegration.integrationId),
diff --git a/src/validations/boards.ts b/src/validations/boards.ts
index 2483b23a5..98ff9410a 100644
--- a/src/validations/boards.ts
+++ b/src/validations/boards.ts
@@ -1,6 +1,10 @@
import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core';
import { z } from 'zod';
-import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
+import {
+ BackgroundImageAttachment,
+ BackgroundImageRepeat,
+ BackgroundImageSize,
+} from '~/types/settings';
export const createBoardSchemaValidation = z.object({
name: z.string().min(2).max(25),
@@ -37,6 +41,9 @@ export const boardCustomizationSchema = z.object({
opacity: z.number().min(10).max(100),
customCss: z.string(),
}),
+ search: z.object({
+ mediaIntegrations: z.array(z.string()),
+ }),
});
export const boardNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);