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