diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 4b5059ae0..fc42cbf05 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -17,6 +17,7 @@
"createItem": "+ create {{item}}",
"sections": {
"settings": "Settings",
+ "integrations": "Integrations",
"dangerZone": "Danger zone"
},
"secrets": {
diff --git a/public/locales/en/settings/integrations.json b/public/locales/en/settings/integrations.json
new file mode 100644
index 000000000..c4875c8ec
--- /dev/null
+++ b/public/locales/en/settings/integrations.json
@@ -0,0 +1,4 @@
+{
+ "title": "Integrations settings",
+ "description": "Here you can configure all the integrations you want to use within Honmarr"
+}
\ No newline at end of file
diff --git a/src/components/Config/Integration/IntegrationModal.tsx b/src/components/Config/Integration/IntegrationModal.tsx
new file mode 100644
index 000000000..9cb47a631
--- /dev/null
+++ b/src/components/Config/Integration/IntegrationModal.tsx
@@ -0,0 +1,138 @@
+import { Accordion, Modal, Stack, Text, Title, rem } from '@mantine/core';
+import { Form, useForm } from '@mantine/form';
+import Image from 'next/image';
+import { useTranslation } from 'react-i18next';
+import { integrationsList } from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector';
+import {
+ IntegrationOptionsRenderer,
+ IntegrationOptionsRendererNoForm,
+} from '~/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
+import { useConfigContext } from '~/config/provider';
+import { AppIntegrationType, AppType, IntegrationType } from '~/types/app';
+
+const ModalTitle = ({ title, description }: { title: string; description: string }) => (
+
+
+ {title}
+
+ {description}
+
+);
+
+function IntegrationDisplay({ integration }: { integration: AppIntegrationType }) {
+ if (!integration.type) return null;
+ return (
+
+ {integration.name}
+
+
+
+
+ );
+}
+
+interface IntegrationGroupedType {
+ type: IntegrationType;
+ integration: AppIntegrationType[];
+}
+
+// export type IntegrationType =
+// | 'readarr'
+// | 'radarr'
+// | 'sonarr'
+// | 'lidarr'
+// | 'sabnzbd'
+// | 'jellyseerr'
+// | 'overseerr'
+// | 'deluge'
+// | 'qBittorrent'
+// | 'transmission'
+// | 'plex'
+// | 'jellyfin'
+// | 'nzbGet'
+// | 'pihole'
+// | 'adGuardHome';
+export function IntegrationsAccordion() {
+ const { config } = useConfigContext();
+
+ console.log(config.integrations);
+ if (!config.integrations) {
+ config.integrations = [];
+ }
+
+ // Fill configIntegrationList with config.integrations in the
+ const configIntegrationList: IntegrationGroupedType[] = [];
+ config.integrations.forEach((configIntegration) => {
+ const existingIntegration = configIntegrationList.find(
+ (integration) => integration.type === configIntegration.type
+ );
+ if (existingIntegration) {
+ existingIntegration.integration.push(configIntegration);
+ } else {
+ configIntegrationList.push({
+ type: configIntegration.type!,
+ integration: [configIntegration],
+ });
+ }
+ });
+
+ return (
+
+ {configIntegrationList.map((configIntegration) => {
+ // Match configIntegration with integrationsList
+ const integration = integrationsList.find(
+ (integration) => integration.value === configIntegration.type
+ );
+ if (!integration) {
+ return null;
+ }
+ return (
+
+
+ }
+ >
+ {integration.label ?? integration.value}
+
+
+
+ {configIntegration.integration.map((item) => (
+
+ ))}
+
+
+
+ );
+ })}
+
+ );
+}
+
+export function IntegrationModal({
+ opened,
+ closeModal,
+}: {
+ opened: boolean;
+ closeModal: () => void;
+}) {
+ const { t } = useTranslation('settings/integrations');
+ return (
+ }
+ opened={opened}
+ onClose={() => closeModal()}
+ size={rem(1000)}
+ >
+
+
+ );
+}
diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/GenericSecretInput.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/GenericSecretInput.tsx
index ba11d8f34..fd680175d 100644
--- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/GenericSecretInput.tsx
+++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/GenericSecretInput.tsx
@@ -106,6 +106,7 @@ export const GenericSecretInput = ({
{displayUpdateField === true ? (
AppType>;
}
+export const integrationsList: SelectItem[] = [
+ {
+ value: 'sabnzbd',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/sabnzbd.svg',
+ label: 'SABnzbd',
+ },
+ {
+ value: 'nzbGet',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
+ label: 'NZBGet',
+ },
+ {
+ value: 'deluge',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/deluge.svg',
+ label: 'Deluge',
+ },
+ {
+ value: 'transmission',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/transmission.svg',
+ label: 'Transmission',
+ },
+ {
+ value: 'qBittorrent',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/qbittorrent.svg',
+ label: 'qBittorrent',
+ },
+ {
+ value: 'jellyseerr',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/jellyseerr.svg',
+ label: 'Jellyseerr',
+ },
+ {
+ value: 'overseerr',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/overseerr.svg',
+ label: 'Overseerr',
+ },
+ {
+ value: 'sonarr',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/sonarr.svg',
+ label: 'Sonarr',
+ },
+ {
+ value: 'radarr',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/radarr.svg',
+ label: 'Radarr',
+ },
+ {
+ value: 'lidarr',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/lidarr.svg',
+ label: 'Lidarr',
+ },
+ {
+ value: 'readarr',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/readarr.svg',
+ label: 'Readarr',
+ },
+ {
+ value: 'jellyfin',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/jellyfin.svg',
+ label: 'Jellyfin',
+ },
+ {
+ value: 'plex',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/plex.svg',
+ label: 'Plex',
+ },
+ {
+ value: 'pihole',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/pi-hole.svg',
+ label: 'PiHole',
+ },
+ {
+ value: 'adGuardHome',
+ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/adguard-home.svg',
+ label: 'AdGuard Home',
+ },
+];
+
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
const { t } = useTranslation('layout/modals/add-app');
- const data: SelectItem[] = [
- {
- value: 'sabnzbd',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png',
- label: 'SABnzbd',
- },
- {
- value: 'nzbGet',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png',
- label: 'NZBGet',
- },
- {
- value: 'deluge',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png',
- label: 'Deluge',
- },
- {
- value: 'transmission',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png',
- label: 'Transmission',
- },
- {
- value: 'qBittorrent',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png',
- label: 'qBittorrent',
- },
- {
- value: 'jellyseerr',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png',
- label: 'Jellyseerr',
- },
- {
- value: 'overseerr',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png',
- label: 'Overseerr',
- },
- {
- value: 'sonarr',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png',
- label: 'Sonarr',
- },
- {
- value: 'radarr',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png',
- label: 'Radarr',
- },
- {
- value: 'lidarr',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png',
- label: 'Lidarr',
- },
- {
- value: 'readarr',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png',
- label: 'Readarr',
- },
- {
- value: 'jellyfin',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png',
- label: 'Jellyfin',
- },
- {
- value: 'plex',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png',
- label: 'Plex',
- },
- {
- value: 'pihole',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png',
- label: 'PiHole',
- },
- {
- value: 'adGuardHome',
- image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png',
- label: 'AdGuard Home',
- },
- ].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
+ const data = integrationsList.filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
if (!value) return [];
diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx
index 9d8a304b8..089a580d9 100644
--- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx
+++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer.tsx
@@ -1,12 +1,14 @@
-import { Stack } from '@mantine/core';
+import { Accordion, Stack } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconKey } from '@tabler/icons-react';
+
import {
- IntegrationField,
- integrationFieldDefinitions,
- integrationFieldProperties,
AppIntegrationPropertyType,
AppType,
+ IntegrationField,
+ IntegrationType,
+ integrationFieldDefinitions,
+ integrationFieldProperties,
} from '../../../../../../../../types/app';
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
@@ -14,6 +16,85 @@ interface IntegrationOptionsRendererProps {
form: UseFormReturnType AppType>;
}
+export const IntegrationOptionsRendererNoForm = ({
+ type,
+ properties,
+}: {
+ type: IntegrationType;
+ properties: any;
+}) => {
+ const selectedIntegration = type;
+ if (!selectedIntegration) return null;
+
+ const displayedProperties = integrationFieldProperties[type];
+
+ return (
+
+ {displayedProperties.map((property, index) => {
+ const [_, definition] = Object.entries(integrationFieldDefinitions).find(
+ ([key]) => property === key
+ )!;
+
+ let indexInFormValue = properties.findIndex((p: any) => p.field === property) ?? -1;
+ if (indexInFormValue === -1) {
+ const { type } = Object.entries(integrationFieldDefinitions).find(
+ ([k, v]) => k === property
+ )![1];
+ const newProperty: AppIntegrationPropertyType = {
+ type,
+ field: property as IntegrationField,
+ isDefined: false,
+ };
+ indexInFormValue = properties.length;
+ }
+ const formValue = properties[indexInFormValue];
+
+ const isPresent = formValue?.isDefined;
+ const accessabilityType = formValue?.type;
+
+ if (!definition) {
+ return (
+ {
+ // form.setFieldValue(`integration.properties.${index}.value`, value);
+ // form.setFieldValue(
+ // `integration.properties.${index}.isDefined`,
+ // value !== undefined
+ // );
+ //TODO: Add a super form to handle saving of the properties
+ }}
+ key={`input-${property}`}
+ label={`${property} (potentionally unmapped)`}
+ secretIsPresent={isPresent}
+ setIcon={IconKey}
+ type={accessabilityType}
+ value={formValue}
+ // {...form.getInputProps(`integration.properties.${index}.value`)}
+ />
+ );
+ }
+
+ return (
+ {
+ // form.setFieldValue(`integration.properties.${index}.value`, value);
+ // form.setFieldValue(`integration.properties.${index}.isDefined`, value !== undefined);
+ // TODO: THIS
+ }}
+ key={`input-${definition.label}`}
+ label={definition.label}
+ secretIsPresent={isPresent}
+ setIcon={definition.icon}
+ type={accessabilityType}
+ value={formValue}
+ // {...form.getInputProps(`integration.properties.${index}.value`)}
+ />
+ );
+ })}
+
+ );
+};
+
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => {
const selectedIntegration = form.values.integration?.type;
diff --git a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx
index dfba66d99..39024d690 100644
--- a/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx
+++ b/src/components/Dashboard/Modals/SelectElement/Components/Overview/AvailableElementsOverview.tsx
@@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
+
import { useConfigContext } from '../../../../../../config/provider';
import { useConfigStore } from '../../../../../../config/store';
import { openContextModalGeneric } from '../../../../../../tools/mantineModalManagerExtensions';
@@ -113,6 +114,8 @@ export const AvailableElementTypes = ({
integration: {
type: null,
properties: [],
+ id: uuidv4(),
+ name: 'New integration',
},
},
allowAppNamePropagation: true,
diff --git a/src/components/Settings/Integration/IntegrationPanel.tsx b/src/components/Settings/Integration/IntegrationPanel.tsx
deleted file mode 100644
index a592fede1..000000000
--- a/src/components/Settings/Integration/IntegrationPanel.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-export function IntegrationPanel() {
- return (
-
-
Integration Panel
-
- );
-}
diff --git a/src/components/layout/header/SettingsMenu.tsx b/src/components/layout/header/SettingsMenu.tsx
index 51baf13bb..cf728c38a 100644
--- a/src/components/layout/header/SettingsMenu.tsx
+++ b/src/components/layout/header/SettingsMenu.tsx
@@ -1,7 +1,15 @@
import { Badge, Button, Menu } from '@mantine/core';
-import { useDisclosure } from '@mantine/hooks';
-import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons-react';
+import { useDisclosure, useHotkeys } from '@mantine/hooks';
+import {
+ IconInfoCircle,
+ IconMenu2,
+ IconPlug,
+ IconPlugConnected,
+ IconSettings,
+} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
+import { IntegrationModal } from '~/components/Config/Integration/IntegrationModal';
+
import { useEditModeInformationStore } from '../../../hooks/useEditModeInformation';
import { AboutModal } from '../../Dashboard/Modals/AboutModal/AboutModal';
import { SettingsDrawer } from '../../Settings/SettingsDrawer';
@@ -12,9 +20,11 @@ import { EditModeToggle } from './SettingsMenu/EditModeToggle';
export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
const [drawerOpened, drawer] = useDisclosure(false);
const { t } = useTranslation('common');
+ const [integrationsModalOpened, integrationsModal] = useDisclosure(false);
const [aboutModalOpened, aboutModal] = useDisclosure(false);
const { classes } = useCardStyles(true);
const { editModeEnabled } = useEditModeInformationStore();
+ useHotkeys([['mod+o', () => integrationsModal.toggle()]]);
return (
<>
@@ -33,6 +43,12 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
{t('sections.settings')}
)}
+ }
+ onClick={integrationsModal.open}
+ >
+ {t('sections.integrations')}
+
}
rightSection={
@@ -53,6 +69,7 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
closeDrawer={drawer.close}
newVersionAvailable={newVersionAvailable}
/>
+
void;
@@ -14,7 +14,7 @@ export type ConfigContextType = {
const ConfigContext = createContext({
name: 'unknown',
- config: undefined,
+ config: {} as ConfigType,
configVersion: undefined,
increaseVersion: () => {},
setConfigName: () => {},
@@ -38,7 +38,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
setConfigVersion((v) => v + 1),
setConfigName: (name: string) => setConfigName(name),
diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts
index 96f25690e..2abb82a0e 100644
--- a/src/tools/server/translation-namespaces.ts
+++ b/src/tools/server/translation-namespaces.ts
@@ -8,6 +8,7 @@ export const dashboardNamespaces = [
'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer',
'settings/common',
+ 'settings/integrations',
'settings/general/theme-selector',
'settings/general/config-changer',
'settings/general/internationalization',
diff --git a/src/types/app.ts b/src/types/app.ts
index 847ef8353..5e27fe377 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -50,6 +50,8 @@ export type IntegrationType =
export type AppIntegrationType = {
type: IntegrationType | null;
+ id: string;
+ name: string;
properties: AppIntegrationPropertyType[];
};
diff --git a/src/types/config.ts b/src/types/config.ts
index 53a8a3f73..2805f81ac 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -1,8 +1,8 @@
-import { CategoryType } from './category';
-import { WrapperType } from './wrapper';
-import { ConfigAppType, AppType } from './app';
-import { SettingsType } from './settings';
import { IWidget } from '../widgets/widgets';
+import { AppIntegrationType, AppType, ConfigAppType } from './app';
+import { CategoryType } from './category';
+import { SettingsType } from './settings';
+import { WrapperType } from './wrapper';
export interface ConfigType {
schemaVersion: number;
@@ -12,6 +12,7 @@ export interface ConfigType {
apps: AppType[];
widgets: IWidget[];
settings: SettingsType;
+ integrations: AppIntegrationType[]
}
export type BackendConfigType = Omit & {
diff --git a/tsconfig.json b/tsconfig.json
index 1ca49efb1..c96f636d5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "es5",
+ "target": "ESNext",
"lib": [
"dom",
"dom.iterable",
@@ -24,7 +24,9 @@
}
],
"paths": {
- "~/*": ["./src/*"]
+ "~/*": [
+ "./src/*"
+ ]
},
},
"include": [
@@ -37,4 +39,4 @@
"exclude": [
"node_modules"
]
-}
+}
\ No newline at end of file