diff --git a/src/components/Config/LoadConfig.tsx b/src/components/Config/LoadConfig.tsx index dfa31f7cd..d9c64c558 100644 --- a/src/components/Config/LoadConfig.tsx +++ b/src/components/Config/LoadConfig.tsx @@ -4,18 +4,20 @@ import { showNotification } from '@mantine/notifications'; import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons-react'; import { setCookie } from 'cookies-next'; import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; import { useConfigStore } from '../../config/store'; import { ConfigType } from '../../types/config'; +import { api } from '~/utils/api'; export const LoadConfigComponent = () => { - const { addConfig } = useConfigStore(); const theme = useMantineTheme(); const { t } = useTranslation('settings/general/config-changer'); + const { mutateAsync: loadAsync } = useLoadConfig(); return ( { - const fileName = files[0].name.replaceAll('.json', ''); + const configName = files[0].name.replaceAll('.json', ''); const fileText = await files[0].text(); try { @@ -32,26 +34,7 @@ export const LoadConfigComponent = () => { } const newConfig: ConfigType = JSON.parse(fileText); - - await addConfig(fileName, newConfig, true); - showNotification({ - autoClose: 5000, - radius: 'md', - title: ( - - {t('dropzone.notifications.loadedSuccessfully.title', { - configName: fileName, - })} - - ), - color: 'green', - icon: , - message: undefined, - }); - setCookie('config-name', fileName, { - maxAge: 60 * 60 * 24 * 30, - sameSite: 'strict', - }); + await loadAsync({ name: configName, config: newConfig }); }} accept={['application/json']} > @@ -89,3 +72,34 @@ export const LoadConfigComponent = () => { ); }; + +const useLoadConfig = () => { + const { t } = useTranslation('settings/general/config-changer'); + const { addConfig } = useConfigStore(); + const router = useRouter(); + return api.config.save.useMutation({ + async onSuccess(_data, variables) { + await addConfig(variables.name, variables.config); + + showNotification({ + autoClose: 5000, + radius: 'md', + title: ( + + {t('dropzone.notifications.loadedSuccessfully.title', { + configName: variables.name, + })} + + ), + color: 'green', + icon: , + message: undefined, + }); + setCookie('config-name', variables.name, { + maxAge: 60 * 60 * 24 * 30, + sameSite: 'strict', + }); + router.push(`/${variables.name}`); + }, + }); +}; diff --git a/src/components/Settings/Common/Config/CreateCopyModal.tsx b/src/components/Settings/Common/Config/CreateCopyModal.tsx index fb924f804..bf1409d1f 100644 --- a/src/components/Settings/Common/Config/CreateCopyModal.tsx +++ b/src/components/Settings/Common/Config/CreateCopyModal.tsx @@ -1,8 +1,11 @@ import { Button, Group, Modal, TextInput, Title } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useTranslation } from 'next-i18next'; +import { IconCheck, IconX } from '@tabler/icons-react'; +import { showNotification } from '@mantine/notifications'; import { useConfigStore } from '../../../../config/store'; -import { useCopyConfigMutation } from '../../../../tools/config/mutations/useCopyConfigMutation'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; interface CreateConfigCopyModalProps { opened: boolean; @@ -16,6 +19,7 @@ export const CreateConfigCopyModal = ({ initialConfigName, }: CreateConfigCopyModalProps) => { const { configs } = useConfigStore(); + const { config } = useConfigContext(); const { t } = useTranslation(['settings/general/config-changer']); const form = useForm({ @@ -40,7 +44,7 @@ export const CreateConfigCopyModal = ({ validateInputOnBlur: true, }); - const { mutateAsync } = useCopyConfigMutation(form.values.configName); + const { mutateAsync } = useCopyConfigMutation(); const handleClose = () => { form.setFieldValue('configName', initialConfigName); @@ -50,7 +54,17 @@ export const CreateConfigCopyModal = ({ const handleSubmit = async (values: typeof form.values) => { if (!form.isValid) return; - await mutateAsync(); + if (!config) { + throw new Error('config is not defiend'); + } + + const copiedConfig = config; + copiedConfig.configProperties.name = form.values.configName; + + await mutateAsync({ + name: form.values.configName, + config: copiedConfig, + }); closeModal(); }; @@ -76,3 +90,33 @@ export const CreateConfigCopyModal = ({ ); }; + +const useCopyConfigMutation = () => { + const { t } = useTranslation(['settings/general/config-changer']); + const utils = api.useContext(); + + return api.config.save.useMutation({ + onSuccess(_data, variables) { + showNotification({ + title: t('modal.copy.events.configCopied.title'), + icon: , + color: 'green', + autoClose: 1500, + radius: 'md', + message: t('modal.copy.events.configCopied.message', { configName: variables.name }), + }); + // Invalidate a query to fetch new config + utils.config.all.invalidate(); + }, + onError(_error, variables) { + showNotification({ + title: t('modal.events.configNotCopied.title'), + icon: , + color: 'red', + autoClose: 1500, + radius: 'md', + message: t('modal.events.configNotCopied.message', { configName: variables.name }), + }); + }, + }); +}; diff --git a/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx b/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx index 481e61f42..3cbe17e15 100644 --- a/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx +++ b/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx @@ -2,13 +2,13 @@ import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core'; import { useHotkeys, useWindowEvent } from '@mantine/hooks'; import { hideNotification, showNotification } from '@mantine/notifications'; import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react'; -import axios from 'axios'; import Consola from 'consola'; import { getCookie } from 'cookies-next'; import { Trans, useTranslation } from 'next-i18next'; import { useConfigContext } from '../../../../../config/provider'; import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan'; +import { api } from '~/utils/api'; import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore'; import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store'; import { useCardStyles } from '../../../useCardStyles'; @@ -28,6 +28,7 @@ export const ToggleEditModeAction = () => { const smallerThanSm = useScreenSmallerThan('sm'); const { config } = useConfigContext(); const { classes } = useCardStyles(true); + const { mutateAsync: saveConfig } = api.config.save.useMutation(); useHotkeys([['mod+E', toggleEditMode]]); @@ -41,11 +42,12 @@ export const ToggleEditModeAction = () => { return undefined; }); - const toggleButtonClicked = () => { + const toggleButtonClicked = async () => { toggleEditMode(); - if (enabled || config === undefined || config?.schemaVersion === undefined) { + if (config === undefined || config?.schemaVersion === undefined) return; + if (enabled) { const configName = getCookie('config-name')?.toString() ?? 'default'; - axios.put(`/api/configs/${configName}`, { ...config }); + await saveConfig({ name: configName, config }); Consola.log('Saved config to server', configName); hideNotification('toggle-edit-mode'); } else if (!enabled) { diff --git a/src/config/store.ts b/src/config/store.ts index bca63e0b7..d4558ad22 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { create } from 'zustand'; import { ConfigType } from '../types/config'; +import { api, trcpProxyClient } from '~/utils/api'; export const useConfigStore = create((set, get) => ({ configs: [], @@ -13,7 +14,7 @@ export const useConfigStore = create((set, get) => ({ ], })); }, - addConfig: async (name: string, config: ConfigType, shouldSaveConfigToFileSystem = true) => { + addConfig: async (name: string, config: ConfigType) => { set((old) => ({ ...old, configs: [ @@ -21,11 +22,6 @@ export const useConfigStore = create((set, get) => ({ { value: config, increaseVersion: () => {} }, ], })); - - if (!shouldSaveConfigToFileSystem) { - return; - } - axios.put(`/api/configs/${name}`, { ...config }); }, removeConfig: (name: string) => { set((old) => ({ @@ -66,7 +62,10 @@ export const useConfigStore = create((set, get) => ({ } if (shouldSaveConfigToFileSystem) { - axios.put(`/api/configs/${name}`, { ...updatedConfig }); + trcpProxyClient.config.save.mutate({ + name, + config: updatedConfig, + }); } }, })); @@ -74,11 +73,7 @@ export const useConfigStore = create((set, get) => ({ interface UseConfigStoreType { configs: { increaseVersion: () => void; value: ConfigType }[]; initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void; - addConfig: ( - name: string, - config: ConfigType, - shouldSaveConfigToFileSystem: boolean - ) => Promise; + addConfig: (name: string, config: ConfigType) => Promise; removeConfig: (name: string) => void; updateConfig: ( name: string, diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts index 5e9e24d1a..eda6361f9 100644 --- a/src/server/api/routers/config.ts +++ b/src/server/api/routers/config.ts @@ -4,6 +4,9 @@ import Consola from 'consola'; import { z } from 'zod'; import { TRPCError } from '@trpc/server'; import { createTRPCRouter, publicProcedure } from '../trpc'; +import { BackendConfigType, ConfigType } from '~/types/config'; +import { getConfig } from '../../../tools/config/getConfig'; +import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; export const configRouter = createTRPCRouter({ all: publicProcedure.query(async () => { @@ -56,4 +59,104 @@ export const configRouter = createTRPCRouter({ message: 'Configuration deleted with success', }; }), + save: publicProcedure + .input( + z.object({ + name: z.string(), + config: z.custom((x) => !!x && typeof x === 'object'), + }) + ) + .mutation(async ({ input }) => { + Consola.info(`Saving updated configuration of '${input.name}' config.`); + + const previousConfig = getConfig(input.name); + + let newConfig: BackendConfigType = { + ...input.config, + apps: [ + ...input.config.apps.map((app) => ({ + ...app, + network: { + ...app.network, + statusCodes: + app.network.okStatus === undefined + ? app.network.statusCodes + : app.network.okStatus.map((x) => x.toString()), + okStatus: undefined, + }, + integration: { + ...app.integration, + properties: app.integration.properties.map((property) => { + if (property.type === 'public') { + return { + field: property.field, + type: property.type, + value: property.value, + }; + } + + const previousApp = previousConfig.apps.find( + (previousApp) => previousApp.id === app.id + ); + + const previousProperty = previousApp?.integration?.properties.find( + (previousProperty) => previousProperty.field === property.field + ); + + if (property.value !== undefined && property.value !== null) { + Consola.info( + 'Detected credential change of private secret. Value will be overwritten in configuration' + ); + return { + field: property.field, + type: property.type, + value: property.value, + }; + } + + return { + field: property.field, + type: property.type, + value: previousProperty?.value, + }; + }), + }, + })), + ], + }; + + newConfig = { + ...newConfig, + widgets: [ + ...newConfig.widgets.map((x) => { + if (x.type !== 'rss') { + return x; + } + + const rssWidget = x as IRssWidget; + + return { + ...rssWidget, + properties: { + ...rssWidget.properties, + rssFeedUrl: + typeof rssWidget.properties.rssFeedUrl === 'string' + ? [rssWidget.properties.rssFeedUrl] + : rssWidget.properties.rssFeedUrl, + }, + } as IRssWidget; + }), + ], + }; + + // Save the body in the /data/config folder with the slug as filename + const targetPath = path.join('data/configs', `${input.name}.json`); + fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); + + Consola.debug(`Config '${input.name}' has been updated and flushed to '${targetPath}'.`); + + return { + message: 'Configuration saved with success', + }; + }), }); diff --git a/src/utils/api.ts b/src/utils/api.ts index 9181abda0..d8e2b3527 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -4,13 +4,38 @@ * * We also create a few inference helpers for input and output types. */ -import { httpBatchLink, loggerLink } from '@trpc/client'; +import { createTRPCProxyClient, httpBatchLink, loggerLink } from '@trpc/client'; import { createTRPCNext } from '@trpc/next'; import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; import superjson from 'superjson'; import { type RootRouter } from '~/server/api/root'; +const getTrpcConfiguration = () => ({ + /** + * Transformer used for data de-serialization from the server. + * + * @see https://trpc.io/docs/data-transformers + */ + transformer: superjson, + + /** + * Links used to determine request flow from client to server. + * + * @see https://trpc.io/docs/links + */ + links: [ + loggerLink({ + enabled: (opts) => + process.env.NODE_ENV === 'development' || + (opts.direction === 'down' && opts.result instanceof Error), + }), + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + ], +}); + const getBaseUrl = () => { if (typeof window !== 'undefined') return ''; // browser should use relative url if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url @@ -20,30 +45,7 @@ const getBaseUrl = () => { /** A set of type-safe react-query hooks for your tRPC API. */ export const api = createTRPCNext({ config() { - return { - /** - * Transformer used for data de-serialization from the server. - * - * @see https://trpc.io/docs/data-transformers - */ - transformer: superjson, - - /** - * Links used to determine request flow from client to server. - * - * @see https://trpc.io/docs/links - */ - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === 'development' || - (opts.direction === 'down' && opts.result instanceof Error), - }), - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - }), - ], - }; + return getTrpcConfiguration(); }, /** * Whether tRPC should await queries when server rendering pages. @@ -66,3 +68,8 @@ export type RouterInputs = inferRouterInputs; * @example type HelloOutput = RouterOutputs['example']['hello'] */ export type RouterOutputs = inferRouterOutputs; + +/** + * A tRPC client that can be used without hooks. + */ +export const trcpProxyClient = createTRPCProxyClient(getTrpcConfiguration());