wip on fixing seerr media request
This commit is contained in:
18 files changed
+794
-194
No files matched your search
@@ -148,7 +148,8 @@ 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 (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
|
||||
@@ -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,
|
||||
@@ -41,6 +41,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 }) => {
|
||||
@@ -314,20 +315,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");
|
||||
@@ -618,8 +606,135 @@ 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();
|
||||
}),
|
||||
});
|
||||
|
||||
interface IntegrationRouterContext {
|
||||
db: Database;
|
||||
session: Parameters<typeof hasQueryAccessToIntegrationsAsync>[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 +755,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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
||||
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);
|
||||
|
||||
@@ -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<inferSearchInteractionOptions<"children"> | null>(null);
|
||||
const t = useI18n();
|
||||
const inputRef = useRef<HTMLInputElement>(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<OpenMediaRequestSearchOptions>).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 (
|
||||
<MantineSpotlight.Root
|
||||
@@ -87,13 +120,13 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh
|
||||
<MantineSpotlight.Search
|
||||
placeholder={`${t("search.placeholder")}...`}
|
||||
ref={inputRef}
|
||||
leftSectionWidth={activeMode.modeKey !== defaultMode ? 80 : 48}
|
||||
leftSectionWidth={hasModeCharacter ? 80 : 48}
|
||||
leftSection={
|
||||
<Group align="center" wrap="nowrap" gap="xs" w="100%" h="100%">
|
||||
<Center w={48} h="100%">
|
||||
<IconSearch stroke={1.5} />
|
||||
</Center>
|
||||
{activeMode.modeKey !== defaultMode ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
{hasModeCharacter ? <Kbd size="sm">{activeMode.character}</Kbd> : null}
|
||||
</Group>
|
||||
}
|
||||
styles={{
|
||||
|
||||
@@ -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<OpenMediaRequestSearchOptions>(mediaRequestSearchEvent, { detail: options }));
|
||||
}
|
||||
|
||||
spotlightActions.open();
|
||||
};
|
||||
|
||||
export { mediaRequestSearchEvent };
|
||||
@@ -12,7 +12,7 @@ const searchInteractions = [
|
||||
createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(),
|
||||
createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise<void> }>(),
|
||||
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<any>["useActions"];
|
||||
|
||||
@@ -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,13 @@ export const useFromIntegrationSearchInteraction = (
|
||||
};
|
||||
};
|
||||
|
||||
const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestChildrenProps>({
|
||||
useActions() {
|
||||
const { openModal } = useModalAction(RequestMediaModal);
|
||||
return [
|
||||
{
|
||||
key: "request",
|
||||
hide: (option) => option.result.inLibrary,
|
||||
hide: (option) => option.result.inLibrary || option.integration.permissions?.hasInteractAccess === false,
|
||||
Component(option) {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconSearch, IconSearchOff } from "@tabler/icons-react";
|
||||
import { IconMovie, IconSearch, IconSearchOff } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -20,6 +20,7 @@ type GroupItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon: TablerIcon | string;
|
||||
disabled?: boolean;
|
||||
useInteraction: (query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||
};
|
||||
|
||||
@@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
);
|
||||
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={item.disabled ? 0.55 : 1}>
|
||||
{icon}
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
@@ -63,6 +64,10 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, {
|
||||
enabled: status !== "loading",
|
||||
});
|
||||
const { data: mediaRequestSearchTargets, ...mediaRequestSearchTargetsQuery } =
|
||||
clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, {
|
||||
enabled: status !== "loading" && session?.user !== undefined,
|
||||
});
|
||||
const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0;
|
||||
const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery(
|
||||
{
|
||||
@@ -77,9 +82,15 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
|
||||
return {
|
||||
isLoading:
|
||||
defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading",
|
||||
isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled),
|
||||
data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t),
|
||||
defaultSearchEngineQuery.isLoading ||
|
||||
mediaRequestSearchTargetsQuery.isLoading ||
|
||||
(resultQuery.isLoading && fromIntegrationEnabled) ||
|
||||
status === "loading",
|
||||
isError:
|
||||
defaultSearchEngineQuery.isError ||
|
||||
mediaRequestSearchTargetsQuery.isError ||
|
||||
(resultQuery.isError && fromIntegrationEnabled),
|
||||
data: createDefaultSearchEntries(defaultSearchEngine, results, mediaRequestSearchTargets, session, query, t),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -87,16 +98,40 @@ export const homeSearchEngineGroup = createGroup<GroupItem>({
|
||||
const createDefaultSearchEntries = (
|
||||
defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null,
|
||||
results: RouterOutputs["integration"]["searchInIntegration"] | undefined,
|
||||
mediaRequestSearchTargets: RouterOutputs["integration"]["mediaRequestSearchTargets"] | undefined,
|
||||
session: Session | null,
|
||||
query: string,
|
||||
t: TranslationFunction,
|
||||
): GroupItem[] => {
|
||||
if (!session?.user && !defaultSearchEngine) {
|
||||
return [];
|
||||
}
|
||||
const mediaRequestSearchEntry: GroupItem = {
|
||||
id: "media-request-search",
|
||||
name: t("search.mode.media.action.search.label"),
|
||||
description:
|
||||
mediaRequestSearchTargets && mediaRequestSearchTargets.length === 0
|
||||
? t("search.mode.media.action.search.disabled.noIntegration")
|
||||
: t("search.mode.media.action.search.description"),
|
||||
icon: IconMovie,
|
||||
disabled: !mediaRequestSearchTargets || mediaRequestSearchTargets.length === 0,
|
||||
useInteraction(query) {
|
||||
if (!mediaRequestSearchTargets || mediaRequestSearchTargets.length === 0) {
|
||||
return {
|
||||
type: "none",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "mode",
|
||||
mode: "media",
|
||||
query,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (!session?.user && !defaultSearchEngine) return [mediaRequestSearchEntry];
|
||||
|
||||
if (!defaultSearchEngine) {
|
||||
return [
|
||||
mediaRequestSearchEntry,
|
||||
{
|
||||
id: "no-default",
|
||||
name: t("search.mode.home.group.search.option.no-default.label"),
|
||||
@@ -115,6 +150,7 @@ const createDefaultSearchEntries = (
|
||||
|
||||
if (defaultSearchEngine.type === "generic") {
|
||||
return [
|
||||
mediaRequestSearchEntry,
|
||||
{
|
||||
id: "search",
|
||||
name: t("search.mode.home.group.search.option.search.label", {
|
||||
@@ -137,6 +173,7 @@ const createDefaultSearchEntries = (
|
||||
|
||||
if (!results) {
|
||||
return [
|
||||
mediaRequestSearchEntry,
|
||||
{
|
||||
id: "from-integration",
|
||||
name: defaultSearchEngine.name,
|
||||
@@ -151,13 +188,16 @@ const createDefaultSearchEntries = (
|
||||
];
|
||||
}
|
||||
|
||||
return results.map((result) => ({
|
||||
id: `search-${result.id}`,
|
||||
name: result.name,
|
||||
description: result.text,
|
||||
icon: result.image ?? IconSearch,
|
||||
useInteraction() {
|
||||
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
|
||||
},
|
||||
}));
|
||||
return [
|
||||
mediaRequestSearchEntry,
|
||||
...results.map((result) => ({
|
||||
id: `search-${result.id}`,
|
||||
name: result.name,
|
||||
description: result.text,
|
||||
icon: result.image ?? IconSearch,
|
||||
useInteraction() {
|
||||
return useFromIntegrationSearchInteraction(defaultSearchEngine, result);
|
||||
},
|
||||
})),
|
||||
];
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { 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 { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createGroup } from "../../lib/group";
|
||||
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
|
||||
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
|
||||
|
||||
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<MediaRequestSearchOption>({
|
||||
keyPath: "key",
|
||||
title: (t) => t("search.mode.media.group.title"),
|
||||
Component(option) {
|
||||
if (option.kind !== "result") {
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
|
||||
{option.kind === "disabled" ? <IconMovie stroke={1.5} /> : <IconSearch stroke={1.5} />}
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{option.label}</Text>
|
||||
<Text size="xs" c="gray.6">
|
||||
{option.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const { result } = option;
|
||||
return (
|
||||
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
|
||||
{result.image ? (
|
||||
<Image src={result.image} w={35} h={50} fit="cover" radius="md" />
|
||||
) : (
|
||||
<IconSearch stroke={1.5} size={35} />
|
||||
)}
|
||||
<Stack gap={2}>
|
||||
<Text>{result.name}</Text>
|
||||
<Text c="dimmed" size="sm" lineClamp={2}>
|
||||
{result.text ?? getIntegrationName(result.integration.kind)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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<MediaRequestSearchScope>({});
|
||||
export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search";
|
||||
|
||||
export const setSelectedAction = (index: number, store: SpotlightStore) => {
|
||||
store.updateState((state) => ({ ...state, selected: index }));
|
||||
};
|
||||
|
||||
@@ -4553,7 +4553,20 @@
|
||||
"media": {
|
||||
"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": "",
|
||||
|
||||
@@ -4553,7 +4553,20 @@
|
||||
"media": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"external": {
|
||||
"help": "Use an external search engine",
|
||||
|
||||
@@ -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,
|
||||
@@ -61,25 +76,46 @@ export default function MediaServerWidget({
|
||||
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Box className={classes.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<ScrollArea
|
||||
className="mediaRequests-list-scrollArea"
|
||||
scrollbarSize="md"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
|
||||
{mediaRequests.map((mediaRequest) => (
|
||||
<MediaRequestCard
|
||||
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
|
||||
request={mediaRequest}
|
||||
isTiny={width <= 256}
|
||||
options={options}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={classes.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface MediaRequestCardProps {
|
||||
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
|
||||
isTiny: boolean;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,122 @@ export default function MediaServerWidget({
|
||||
const isTiny = width < 256;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group className="mediaRequests-stats-stat-stack" justify="center" align="center" gap="xs" w="100%">
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
<Box className={searchClasses.searchRoot}>
|
||||
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
|
||||
<Stack
|
||||
className="mediaRequests-stats-layout"
|
||||
h="100%"
|
||||
gap="xs"
|
||||
p="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-stats-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.stats.main")}
|
||||
</Text>
|
||||
<Grid className="mediaRequests-stats-stats-grid" gap={4} w="100%">
|
||||
{data.map((stat) => (
|
||||
<Grid.Col
|
||||
className={combineClasses("mediaRequests-stats-stat-wrapper", `mediaRequests-stats-stat-${stat.name}`)}
|
||||
key={stat.name}
|
||||
span={isTiny ? 6 : 3}
|
||||
>
|
||||
<Tooltip label={t(`titles.stats.${stat.name}`)}>
|
||||
<Card p={0} radius={board.itemRadius} className={classes.card}>
|
||||
<Group
|
||||
className="mediaRequests-stats-stat-stack"
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="xs"
|
||||
w="100%"
|
||||
>
|
||||
<stat.icon className="mediaRequests-stats-stat-icon" size={16} />
|
||||
<Text className="mediaRequests-stats-stat-value" size="md">
|
||||
{stat.number}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack
|
||||
className="mediaRequests-stats-users-wrapper"
|
||||
flex={1}
|
||||
w="100%"
|
||||
gap={4}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group
|
||||
className="mediaRequests-stats-users-user-group"
|
||||
h="100%"
|
||||
p={0}
|
||||
gap="sm"
|
||||
justify="space-between"
|
||||
>
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
<Stack gap={4} w="100%">
|
||||
<Text className="mediaRequests-stats-users-title" fw="bold" ta="center" size={isTiny ? "xs" : "sm"}>
|
||||
{t("titles.users.main")} ({t("titles.users.requests")})
|
||||
</Text>
|
||||
<Stack className="mediaRequests-stats-users-wrapper" flex={1} w="100%" gap={4} style={{ overflow: "hidden" }}>
|
||||
{requestStats.users.slice(0, 10).map((user) => (
|
||||
<Card
|
||||
component="a"
|
||||
href={user.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={combineClasses(
|
||||
"mediaRequests-stats-users-user-wrapper",
|
||||
`mediaRequests-stats-users-user-${user.id}`,
|
||||
classes.card,
|
||||
)}
|
||||
key={user.id}
|
||||
p="xs"
|
||||
radius={board.itemRadius}
|
||||
>
|
||||
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="sm" justify="space-between">
|
||||
<Group gap={4}>
|
||||
<Tooltip label={user.integration.name}>
|
||||
<Avatar
|
||||
className="mediaRequests-stats-users-user-avatar"
|
||||
size={20}
|
||||
src={user.avatar}
|
||||
bd={`2px solid ${user.integration.kind === "overseerr" ? OVERSEERR_COLOR : JELLYSEERR_COLOR}`}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Text className="mediaRequests-stats-users-user-userName" size="sm">
|
||||
{user.displayName}
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text className="mediaRequests-stats-users-user-request-count" size="md" fw={500}>
|
||||
{user.requestCount}
|
||||
</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => {
|
||||
const t = useScopedI18n("search.mode.media");
|
||||
|
||||
return (
|
||||
<Tooltip label={t("action.search.label")}>
|
||||
<ActionIcon
|
||||
className={searchClasses.searchButton}
|
||||
variant="light"
|
||||
size="sm"
|
||||
aria-label={t("action.search.label")}
|
||||
onClick={() => openMediaRequestSearch({ integrationIds })}
|
||||
>
|
||||
<IconSearch size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user