diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index b7fbf3291..2f610328a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -148,7 +148,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const integrationCategories = integrationDefs[searchParams.kind].category.flat(); + const supportsSearchEngine = + integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return (
void handleSubmitAsync(value))}> diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5591b74ed..4375644cc 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,11 +16,9 @@ import { integrations, integrationSecrets, integrationUserPermissions, - searchEngines, } from "@homarr/db/schema"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, integrationCategories, @@ -33,6 +33,7 @@ import { integrationSavePermissionsSchema, integrationUpdateSchema, } from "@homarr/validation/integration"; +import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; @@ -41,6 +42,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn import { mapTestConnectionError } from "./map-test-connection-error"; const logger = createLogger({ module: "integrationRouter" }); +const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { @@ -80,7 +82,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -128,7 +130,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -179,7 +181,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -314,20 +316,7 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); - if ( - input.attemptSearchEngineCreation && - integrationDefs[input.kind].category.flatMap((category) => category).includes("search") - ) { - const icon = getIconUrl(input.kind); - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - integrationId, - type: "fromIntegration", - iconUrl: icon, - short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name), - }); - } + // Media request search is a first-class Spotlight action. Do not create a search engine for it. }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -495,7 +484,7 @@ export const integrationRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -503,7 +492,7 @@ export const integrationRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupIntegrationPermission @@ -514,7 +503,7 @@ export const integrationRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; @@ -618,8 +607,149 @@ export const integrationRouter = createTRPCRouter({ const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.searchAsync(encodeURI(input.query)); }), + mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => { + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx); + + return integrationsWithAccess.map((integration) => { + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }; + }); + }), + searchMediaRequests: protectedProcedure + .input( + z.object({ + query: z.string(), + integrationIds: z.array(z.string()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const query = input.query.trim(); + if (query.length === 0) return []; + + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds); + + const results = await Promise.all( + integrationsWithAccess.map(async (integration) => { + const integrationInstance = await createIntegrationAsync({ + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind as (typeof mediaRequestSearchKinds)[number], + externalUrl: integration.app?.href ?? null, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) => + searchResults.map((result) => ({ + ...result, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }, + })), + ); + }), + ); + + return results.flat(); + }), + getMediaRequestOptions: protectedProcedure + .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestOptionsSchema) + .query(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestRequestSchema) + .mutation(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), }); +interface IntegrationRouterContext { + db: Database; + session: Parameters[2]; +} + +const getAccessibleMediaRequestSearchIntegrationsAsync = async ( + ctx: IntegrationRouterContext, + integrationIds?: string[], +) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + where: + integrationIds && integrationIds.length > 0 + ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds)) + : inArray(integrations.kind, mediaRequestSearchKinds), + orderBy: asc(integrations.name), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + if (integrationIds && integrationsFromDb.length !== integrationIds.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "One or more media request search integrations were not found", + }); + } + + const integrationsWithAccess = await Promise.all( + integrationsFromDb.map(async (integration) => ({ + integration, + hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session), + })), + ); + + if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query one or more media request search integrations", + }); + } + + return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration); +}; + interface UpdateSecretInput { integrationId: string; value: string; @@ -640,35 +770,6 @@ interface AddSecretInput { kind: IntegrationSecretKind; } -const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => { - const searchEngines = await db.query.searchEngines.findMany({ - columns: { - short: true, - }, - }); - - const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase()); - const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase(); - - if (!usedShortNames.includes(nameByIntegrationName)) { - return nameByIntegrationName; - } - - // 8 is max length constraint - for (let i = 2; i < 9999999; i++) { - const generatedName = `${nameByIntegrationName}${i}`; - if (usedShortNames.includes(generatedName)) { - continue; - } - - return generatedName; - } - - throw new Error( - "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.", - ); -}; - const addSecretAsync = async (db: Database, input: AddSecretInput) => { await db.insert(integrationSecrets).values({ kind: input.kind, diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index ddfc892a9..8c72baf6e 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { asc, eq, like } from "@homarr/db"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { searchEngines, users } from "@homarr/db/schema"; -import { createIntegrationAsync } from "@homarr/integrations"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; -import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "searchEngineRouter" }); @@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - getMediaRequestOptions: protectedProcedure - .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) - .input(mediaRequestOptionsSchema) - .query(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); - }), - requestMedia: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) - .input(mediaRequestRequestSchema) - .mutation(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); - }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(searchEngineManageSchema) diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 17927b9c4..a7cdddfa2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; +import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -59,6 +59,127 @@ describe("all should return all integrations", () => { }); }); +describe("mediaRequestSearchTargets should return accessible media search integrations", () => { + test("with integration use access should only return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }, + { + id: "2", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }, + ]); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + expect(result[0]!.kind).toBe("jellyseerr"); + }); + + test("without access should not return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(0); + }); + + test("with direct integration use access should return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + await db.insert(users).values({ id: defaultUserId }); + await db.insert(integrationUserPermissions).values({ + integrationId: "1", + userId: defaultUserId, + permission: "use", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); +}); + +describe("searchMediaRequests should validate scoped integrations", () => { + test("with a non media search integration should throw not found", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "One or more media request search integrations were not found", + ); + }); + + test("without query access to a scoped media search integration should throw forbidden", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "User does not have permission to query one or more media request search integrations", + ); + }); +}); + describe("byId should return an integration by id", () => { test("with full access should return an integration by id", async () => { const db = createDb(); @@ -208,7 +329,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.updatedAt).toEqual(fakeNow); }); - test("with create integration access should create a new integration when creating search engine", async () => { + test("with create integration access should not create a search engine for media request search integrations", async () => { const db = createDb(); const caller = integrationRouter.createCaller({ db, @@ -243,12 +364,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.updatedAt).toEqual(fakeNow); - expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); - expect(dbSearchEngine!.short).toBe("j"); - expect(dbSearchEngine!.name).toBe(input.name); - expect(dbSearchEngine!.iconUrl).toBe( - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", - ); + expect(dbSearchEngine).toBeUndefined(); }); test("with create integration access should create a new integration with new linked app", async () => { diff --git a/packages/integrations/src/interfaces/media-requests/media-request-types.ts b/packages/integrations/src/interfaces/media-requests/media-request-types.ts index 4d7b094ff..d475764bf 100644 --- a/packages/integrations/src/interfaces/media-requests/media-request-types.ts +++ b/packages/integrations/src/interfaces/media-requests/media-request-types.ts @@ -12,12 +12,14 @@ interface SeriesInformation { overview: string; seasons: SerieSeason[]; posterPath: string; + requestedSeasons?: number[]; } interface MovieInformation { id: number; overview: string; posterPath: string; + requestedSeasons?: number[]; } export type MediaInformation = SeriesInformation | MovieInformation; diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 619af2541..643d2ac60 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -60,6 +60,9 @@ export class OverseerrIntegration text: "overview" in result ? result.overview : undefined, type: result.mediaType, inLibrary: result.mediaInfo !== undefined, + availability: result.mediaInfo + ? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false) + : undefined, })); } @@ -70,7 +73,12 @@ export class OverseerrIntegration "X-Api-Key": this.getSecretValue("apiKey"), }, }); - return await mediaInformationSchema.parseAsync(await response.json()); + const data = await mediaInformationSchema.parseAsync(await response.json()); + const requestedSeasons = [ + ...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []), + ]; + const { mediaInfo: _strip, ...rest } = data; + return { ...rest, requestedSeasons }; } /** @@ -341,6 +349,22 @@ interface MovieInformation { releaseDate: string; } +const mediaInfoRequestsSchema = z + .object({ + requests: z + .array( + z.object({ + seasons: z.array( + z.object({ + seasonNumber: z.number(), + }), + ), + }), + ) + .optional(), + }) + .optional(); + const mediaInformationSchema = z.union([ z.object({ id: z.number(), @@ -355,14 +379,22 @@ const mediaInformationSchema = z.union([ ), numberOfSeasons: z.number(), posterPath: z.string().startsWith("/"), + mediaInfo: mediaInfoRequestsSchema, }), z.object({ id: z.number(), overview: z.string(), posterPath: z.string().startsWith("/"), + mediaInfo: mediaInfoRequestsSchema, }), ]); +const searchMediaInfoSchema = z + .object({ + status: z.number(), + }) + .optional(); + const searchSchema = z.object({ results: z .array( @@ -373,7 +405,7 @@ const searchSchema = z.object({ name: z.string(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), overview: z.string(), - mediaInfo: z.object({}).optional(), + mediaInfo: searchMediaInfoSchema, }), z.object({ id: z.number(), @@ -381,14 +413,14 @@ const searchSchema = z.object({ title: z.string(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), overview: z.string(), - mediaInfo: z.object({}).optional(), + mediaInfo: searchMediaInfoSchema, }), z.object({ id: z.number(), mediaType: z.literal("person"), name: z.string(), profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(), - mediaInfo: z.object({}).optional(), + mediaInfo: searchMediaInfoSchema, }), ]), ) diff --git a/packages/modals-collection/src/search-engines/request-media-modal.tsx b/packages/modals-collection/src/search-engines/request-media-modal.tsx index 89306f3af..c75647969 100644 --- a/packages/modals-collection/src/search-engines/request-media-modal.tsx +++ b/packages/modals-collection/src/search-engines/request-media-modal.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; +import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import { MRT_Table } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; @@ -16,13 +16,13 @@ interface RequestMediaModalProps { } export const RequestMediaModal = createModal(({ actions, innerProps }) => { - const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({ + const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({ integrationId: innerProps.integrationId, mediaId: innerProps.mediaId, mediaType: innerProps.mediaType, }); - const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({ + const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({ onSuccess() { actions.closeModal(); showSuccessNotification({ @@ -33,6 +33,7 @@ export const RequestMediaModal = createModal(({ actions, const isPending = isPendingQuery || isPendingMutation; const t = useI18n(); + const requestedSeasons = new Set(data?.requestedSeasons ?? []); const columns = useMemo[]>( () => [ @@ -44,8 +45,18 @@ export const RequestMediaModal = createModal(({ actions, accessorKey: "episodeCount", header: t("search.engine.media.request.modal.table.header.episodes"), }, + { + id: "status", + header: t("search.engine.media.request.modal.table.header.status"), + Cell: ({ row }) => + requestedSeasons.has(row.original.seasonNumber) ? ( + + {t("search.engine.media.request.modal.table.status.requested")} + + ) : null, + }, ], - [], + [data?.requestedSeasons], ); const table = useTranslatedMantineReactTable({ @@ -56,7 +67,7 @@ export const RequestMediaModal = createModal(({ actions, enablePagination: false, enableSorting: false, enableSelectAll: true, - enableRowSelection: true, + enableRowSelection: (row: MRT_Row) => !requestedSeasons.has(row.original.seasonNumber), mantineTableProps: { highlightOnHover: false, striped: "odd", diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index eb48e04b2..4b40ca5be 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = setQuery(interaction.query); setTimeout(() => selectAction(0, spotlightStore)); } else if (interaction.type === "mode") { + if (interaction.query !== undefined) { + setQuery(interaction.query); + } setMode(interaction.mode); } else if (interaction.type === "children") { setChildrenOptions(interaction); diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 4ca086f3b..acf584615 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -1,20 +1,28 @@ "use client"; import type { Dispatch, SetStateAction } from "react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { IconSearch, IconX } from "@tabler/icons-react"; +import { useSetAtom } from "jotai"; import { hotkeys } from "@homarr/definitions"; import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; +import type { OpenMediaRequestSearchOptions } from "../index"; import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; import { useHomeEmptyGroups } from "../modes/help/home-empty-groups"; -import { selectAction, spotlightStore } from "../spotlight-store"; +import { + mediaRequestSearchEvent, + mediaRequestSearchScopeAtom, + selectAction, + spotlightActions, + spotlightStore, +} from "../spotlight-store"; import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightActionGroups } from "./actions/groups/action-group"; @@ -49,10 +57,35 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh const [childrenOptions, setChildrenOptions] = useState | null>(null); const t = useI18n(); const inputRef = useRef(null); + const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom); // Works as always the same amount of hooks are executed const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const groups = useGroups(); const homeEmptyGroups = useHomeEmptyGroups(); + const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined; + + useEffect(() => { + const handleMediaRequestSearch = (event: Event) => { + const { integrationIds, query } = (event as CustomEvent).detail ?? {}; + setMediaRequestSearchScope({ integrationIds }); + setMode("media"); + setChildrenOptions(null); + setQuery(query ?? ""); + spotlightActions.open(); + setTimeout(() => selectAction(0, spotlightStore)); + }; + + window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); + return () => { + window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); + }; + }, [setMediaRequestSearchScope, setMode, setQuery]); + + useEffect(() => { + if (mode !== "media") { + setMediaRequestSearchScope({}); + } + }, [mode, setMediaRequestSearchScope]); return (
- {activeMode.modeKey !== defaultMode ? {activeMode.character} : null} + {hasModeCharacter ? {activeMode.character} : null} } styles={{ diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index 18298a57b..3433b7125 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -1,9 +1,9 @@ "use client"; -import { spotlightActions } from "./spotlight-store"; +import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store"; export { Spotlight } from "./components/spotlight"; -export { openSpotlight }; +export { openSpotlight, openMediaRequestSearch }; export { SpotlightProvider, useRegisterSpotlightContextResults, @@ -11,3 +11,18 @@ export { } from "./modes/home/context"; const openSpotlight = spotlightActions.open; + +export interface OpenMediaRequestSearchOptions { + integrationIds?: string[]; + query?: string; +} + +const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(mediaRequestSearchEvent, { detail: options })); + } + + spotlightActions.open(); +}; + +export { mediaRequestSearchEvent }; diff --git a/packages/spotlight/src/lib/interaction.ts b/packages/spotlight/src/lib/interaction.ts index 060a35c51..79ca26bc7 100644 --- a/packages/spotlight/src/lib/interaction.ts +++ b/packages/spotlight/src/lib/interaction.ts @@ -12,7 +12,7 @@ const searchInteractions = [ createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise }>(), createSearchInteraction("setQuery").optionsType<{ query: string }>(), - createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(), + createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(), createSearchInteraction("children").optionsType<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any useActions: CreateChildrenOptionsProps["useActions"]; diff --git a/packages/spotlight/src/modes/external/search-engines-search-group.tsx b/packages/spotlight/src/modes/external/search-engines-search-group.tsx index da965725b..34890b159 100644 --- a/packages/spotlight/src/modes/external/search-engines-search-group.tsx +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -64,6 +64,36 @@ type MediaRequestChildrenProps = { kind: IntegrationKind; url: string; id: string; + permissions?: { + hasInteractAccess: boolean; + }; + }; +}; + +export const useMediaRequestSearchInteraction = ( + integration: MediaRequestChildrenProps["integration"], + searchResult: FromIntegrationSearchResult, +): inferSearchInteractionDefinition<"link" | "children"> => { + const { openSearchInNewTab } = useSettings(); + + const type = searchResult.type; + if (type === "person") { + return { + type: "link", + href: searchResult.link, + newTab: openSearchInNewTab, + }; + } + + return { + type: "children", + ...mediaRequestsChildrenOptions({ + result: { + ...searchResult, + type, + }, + integration, + }), }; }; @@ -71,8 +101,6 @@ export const useFromIntegrationSearchInteraction = ( searchEngine: SearchEngine, searchResult: FromIntegrationSearchResult, ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { - const { openSearchInNewTab } = useSettings(); - if (searchEngine.type !== "fromIntegration") { throw new Error("Invalid search engine type"); } @@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = ( ) && "type" in searchResult ) { - const type = searchResult.type; - if (type === "person") { - return { - type: "link", - href: searchResult.link, - newTab: openSearchInNewTab, - }; - } - - return { - type: "children", - ...mediaRequestsChildrenOptions({ - result: { - ...searchResult, - type, - }, - integration: searchEngine.integration, - }), - }; + return useMediaRequestSearchInteraction(searchEngine.integration, searchResult); } return { @@ -115,13 +125,15 @@ export const useFromIntegrationSearchInteraction = ( }; }; -const mediaRequestsChildrenOptions = createChildrenOptions({ +export const mediaRequestsChildrenOptions = createChildrenOptions({ useActions() { const { openModal } = useModalAction(RequestMediaModal); return [ { key: "request", - hide: (option) => option.result.inLibrary, + hide: (option) => + (option.result.type === "movie" && option.result.inLibrary) || + option.integration.permissions?.hasInteractAccess === false, Component(option) { const t = useScopedI18n("search.mode.media"); return ( diff --git a/packages/spotlight/src/modes/help/home-empty-groups.tsx b/packages/spotlight/src/modes/help/home-empty-groups.tsx index e035e66b9..28933696b 100644 --- a/packages/spotlight/src/modes/help/home-empty-groups.tsx +++ b/packages/spotlight/src/modes/help/home-empty-groups.tsx @@ -19,6 +19,7 @@ import type { SearchMode } from "../../lib/mode"; import { appIntegrationBoardMode } from "../app-integration-board"; import { commandMode } from "../command"; import { externalMode } from "../external"; +import { mediaMode } from "../media"; import { pageMode } from "../page"; import { userGroupMode } from "../user-group"; @@ -33,7 +34,7 @@ type QuickLinkOption = { export const useHomeEmptyGroups = () => { const { data: session } = useSession(); const tPages = useScopedI18n("search.mode.page.group.page.option"); - const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode]; + const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode]; if (session?.user.permissions.includes("admin")) { visibleSearchModes.unshift(userGroupMode); @@ -87,7 +88,7 @@ export const useHomeEmptyGroups = () => { useInteraction: interaction.link(({ path }) => ({ href: path })), }), createGroup({ - keyPath: "character", + keyPath: "modeKey", title: (t) => t("search.mode.help.group.mode.title"), options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), Component: ({ modeKey, character }) => { @@ -96,7 +97,7 @@ export const useHomeEmptyGroups = () => { return ( {t("help")} - {character} + {character && {character}} ); }, diff --git a/packages/spotlight/src/modes/home/home-search-engine-group.tsx b/packages/spotlight/src/modes/home/home-search-engine-group.tsx index 40e40f131..799d2216d 100644 --- a/packages/spotlight/src/modes/home/home-search-engine-group.tsx +++ b/packages/spotlight/src/modes/home/home-search-engine-group.tsx @@ -20,6 +20,7 @@ type GroupItem = { name: string; description?: string; icon: TablerIcon | string; + disabled?: boolean; useInteraction: (query: string) => inferSearchInteractionDefinition; }; @@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup({ ); return ( - + {icon} {item.name} @@ -91,9 +92,7 @@ const createDefaultSearchEntries = ( query: string, t: TranslationFunction, ): GroupItem[] => { - if (!session?.user && !defaultSearchEngine) { - return []; - } + if (!session?.user && !defaultSearchEngine) return []; if (!defaultSearchEngine) { return [ diff --git a/packages/spotlight/src/modes/index.tsx b/packages/spotlight/src/modes/index.tsx index fa6e81916..0c860069f 100644 --- a/packages/spotlight/src/modes/index.tsx +++ b/packages/spotlight/src/modes/index.tsx @@ -2,6 +2,7 @@ import { appIntegrationBoardMode } from "./app-integration-board"; import { commandMode } from "./command"; import { externalMode } from "./external"; import { homeMode } from "./home"; +import { mediaMode } from "./media"; import { pageMode } from "./page"; import { userGroupMode } from "./user-group"; @@ -11,5 +12,6 @@ export const searchModes = [ externalMode, commandMode, pageMode, + mediaMode, homeMode, ] as const; diff --git a/packages/spotlight/src/modes/media/index.tsx b/packages/spotlight/src/modes/media/index.tsx new file mode 100644 index 000000000..46b7a661f --- /dev/null +++ b/packages/spotlight/src/modes/media/index.tsx @@ -0,0 +1,8 @@ +import type { SearchMode } from "../../lib/mode"; +import { mediaRequestSearchGroup } from "./media-request-search-group"; + +export const mediaMode = { + modeKey: "media", + character: undefined, + groups: [mediaRequestSearchGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/media/media-request-search-group.tsx b/packages/spotlight/src/modes/media/media-request-search-group.tsx new file mode 100644 index 000000000..3b4f42168 --- /dev/null +++ b/packages/spotlight/src/modes/media/media-request-search-group.tsx @@ -0,0 +1,174 @@ +import { Badge, Group, Image, Stack, Text } from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { IconMovie, IconSearch } from "@tabler/icons-react"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { getIntegrationName } from "@homarr/definitions"; +import type { MediaAvailability } from "@homarr/integrations/types"; +import { mediaAvailabilityConfiguration } from "@homarr/integrations/types"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import { mediaRequestSearchScopeAtom } from "../../spotlight-store"; +import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group"; + +const availabilityLabelKeys = ["available", "partiallyAvailable", "processing", "requested", "pending"] as const; +type AvailabilityWithLabel = (typeof availabilityLabelKeys)[number]; + +type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number]; + +type MediaRequestSearchOption = + | { + key: string; + kind: "disabled" | "hint"; + label: string; + description: string; + } + | { + key: string; + kind: "result"; + result: MediaRequestSearchResult; + }; + +export const mediaRequestSearchGroup = createGroup({ + keyPath: "key", + title: (t) => t("search.mode.media.group.title"), + Component(option) { + const tMedia = useScopedI18n("search.mode.media"); + + if (option.kind !== "result") { + return ( + + {option.kind === "disabled" ? : } + + {option.label} + + {option.description} + + + + ); + } + + const { result } = option; + const hasAvailabilityLabel = + result.availability && + (availabilityLabelKeys as readonly string[]).includes(result.availability); + const badgeLabel = hasAvailabilityLabel + ? tMedia(`availability.${result.availability as AvailabilityWithLabel}`) + : undefined; + const badgeColor = result.availability + ? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color + : undefined; + + return ( + + {result.image ? ( + + ) : ( + + )} + + + {result.name} + {badgeLabel && ( + + {badgeLabel} + + )} + + + {result.text ?? getIntegrationName(result.integration.kind)} + + + + ); + }, + useInteraction(option) { + if (option.kind !== "result") { + return { + type: "none", + }; + } + + return useMediaRequestSearchInteraction(option.result.integration, option.result); + }, + useQueryOptions(query) { + const tMedia = useScopedI18n("search.mode.media"); + const scope = useAtomValue(mediaRequestSearchScopeAtom); + const [debouncedQuery] = useDebouncedValue(query.trim(), 150); + const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(); + + const scopedTargets = (targetsQuery.data ?? []).filter((target) => + scope.integrationIds ? scope.integrationIds.includes(target.id) : true, + ); + const hasScopedTargets = scopedTargets.length > 0; + const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets; + const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined; + + const resultsQuery = clientApi.integration.searchMediaRequests.useQuery( + { query: debouncedQuery, integrationIds }, + { + enabled: shouldSearch, + placeholderData: keepPreviousData, + select: (data) => data.slice(0, 10), + }, + ); + + const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading); + const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError); + + if (!targetsQuery.data) { + return { + isLoading, + isError, + data: [], + }; + } + + if (!hasScopedTargets) { + return { + isLoading, + isError, + data: [ + { + key: "disabled", + kind: "disabled", + label: tMedia("action.search.label"), + description: scope.integrationIds + ? tMedia("action.search.disabled.scoped") + : tMedia("action.search.disabled.noIntegration"), + }, + ], + }; + } + + if (debouncedQuery.length === 0) { + return { + isLoading, + isError, + data: [ + { + key: "hint", + kind: "hint", + label: tMedia("action.search.label"), + description: tMedia("action.search.description"), + }, + ], + }; + } + + return { + isLoading, + isError, + data: (resultsQuery.data ?? []).map((result) => ({ + key: `result-${result.integration.id}-${result.type}-${result.id}`, + kind: "result", + result, + })), + }; + }, +}); diff --git a/packages/spotlight/src/spotlight-store.ts b/packages/spotlight/src/spotlight-store.ts index 052ac59cb..a144cdc94 100644 --- a/packages/spotlight/src/spotlight-store.ts +++ b/packages/spotlight/src/spotlight-store.ts @@ -3,9 +3,17 @@ import { clamp } from "@mantine/hooks"; import type { SpotlightStore } from "@mantine/spotlight"; import { createSpotlight } from "@mantine/spotlight"; +import { atom } from "jotai"; export const [spotlightStore, spotlightActions] = createSpotlight(); +export interface MediaRequestSearchScope { + integrationIds?: string[]; +} + +export const mediaRequestSearchScopeAtom = atom({}); +export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search"; + export const setSelectedAction = (index: number, store: SpotlightStore) => { store.updateState((state) => ({ ...state, selected: index })); }; diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 38741544f..ecc3d0bb4 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -4555,9 +4555,23 @@ } }, "media": { + "help": "Search for movies or shows to request", "requestMovie": "", "requestSeries": "", - "openIn": "" + "openIn": "", + "group": { + "title": "Movies and shows" + }, + "action": { + "search": { + "label": "Search for movies or shows", + "description": "Start typing to search your media request integrations", + "disabled": { + "noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.", + "scoped": "This widget does not have an accessible media request integration." + } + } + } }, "external": { "help": "", @@ -4833,7 +4847,11 @@ "table": { "header": { "season": "Season", - "episodes": "Episodes" + "episodes": "Episodes", + "status": "Status" + }, + "status": { + "requested": "Requested" } }, "button": { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a8f08d222..7607c4002 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4562,9 +4562,30 @@ } }, "media": { + "help": "Search for movies or shows to request", "requestMovie": "Request movie", "requestSeries": "Request series", - "openIn": "Open in {kind}" + "openIn": "Open in {kind}", + "group": { + "title": "Movies and shows" + }, + "action": { + "search": { + "label": "Search for movies or shows", + "description": "Start typing to search your media request integrations", + "disabled": { + "noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.", + "scoped": "This widget does not have an accessible media request integration." + } + } + }, + "availability": { + "available": "Available", + "partiallyAvailable": "Partial", + "processing": "Processing", + "requested": "Requested", + "pending": "Requested" + } }, "external": { "help": "Use an external search engine", @@ -4840,7 +4861,11 @@ "table": { "header": { "season": "Season", - "episodes": "Episodes" + "episodes": "Episodes", + "status": "Status" + }, + "status": { + "requested": "Requested" } }, "button": { diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 5d86c40a1..3a17c5e0f 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -1,17 +1,32 @@ "use client"; -import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; -import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; +import { + ActionIcon, + Anchor, + Avatar, + Badge, + Box, + Card, + Group, + Image, + ScrollArea, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react"; import type { RouterInputs, RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { MediaRequestStatus } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; +import classes from "../search-button.module.css"; export default function MediaServerWidget({ integrationIds, @@ -43,7 +58,7 @@ export default function MediaServerWidget({ const newData = filteredData.concat( data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), ); - return newData.sort((dataA, dataB) => { + return newData.toSorted((dataA, dataB) => { if (dataA.status === dataB.status) { return dataB.createdAt.getTime() - dataA.createdAt.getTime(); } @@ -61,25 +76,46 @@ export default function MediaServerWidget({ if (mediaRequests.length === 0) throw new NoIntegrationDataError(); return ( - - - {mediaRequests.map((mediaRequest) => ( - - ))} - - + + {!isEditMode && } + + + {mediaRequests.map((mediaRequest) => ( + + ))} + + + ); } +const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { + const t = useScopedI18n("search.mode.media"); + + return ( + + openMediaRequestSearch({ integrationIds })} + > + + + + ); +}; + interface MediaRequestCardProps { request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; isTiny: boolean; diff --git a/packages/widgets/src/media-requests/search-button.module.css b/packages/widgets/src/media-requests/search-button.module.css new file mode 100644 index 000000000..b131da24d --- /dev/null +++ b/packages/widgets/src/media-requests/search-button.module.css @@ -0,0 +1,24 @@ +.searchRoot { + position: relative; + height: 100%; +} + +.searchButton { + position: absolute; + top: var(--mantine-spacing-xs); + right: var(--mantine-spacing-xs); + z-index: 2; + opacity: 0; + transition: opacity 120ms ease; +} + +.searchRoot:hover .searchButton, +.searchRoot:focus-within .searchButton { + opacity: 1; +} + +@media (hover: none) { + .searchButton { + opacity: 1; + } +} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 4a1b6ed09..2960d7ae6 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core"; import type { Icon } from "@tabler/icons-react"; import { IconDeviceTv, @@ -9,6 +9,7 @@ import { IconMovie, IconPlayerPlay, IconReceipt, + IconSearch, IconThumbDown, IconThumbUp, } from "@tabler/icons-react"; @@ -17,11 +18,13 @@ import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { RequestStats } from "@homarr/integrations/types"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "./component.module.css"; +import searchClasses from "../search-button.module.css"; const OVERSEERR_COLOR = "#ECB000"; const JELLYSEERR_COLOR = "#6677CC"; @@ -93,83 +96,104 @@ export default function MediaServerWidget({ const isTiny = width < 256; return ( - - - - {t("titles.stats.main")} - - - {data.map((stat) => ( - - - - - - - {stat.number} + + {!isEditMode && } + + + + {t("titles.stats.main")} + + + {data.map((stat) => ( + + + + + + + {stat.number} + + + + + + ))} + + + + + {t("titles.users.main")} ({t("titles.users.requests")}) + + + {requestStats.users.slice(0, 10).map((user) => ( + + + + + + + + {user.displayName} - - - - ))} - - - - - {t("titles.users.main")} ({t("titles.users.requests")}) - - - {requestStats.users.slice(0, 10).map((user) => ( - - - - - - - - {user.displayName} + + + {user.requestCount} - - - {user.requestCount} - - - - ))} + + ))} + - + ); } + +const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { + const t = useScopedI18n("search.mode.media"); + + return ( + + openMediaRequestSearch({ integrationIds })} + > + + + + ); +};