diff --git a/next.config.js b/next.config.js index dde3ec977..0abbde49c 100644 --- a/next.config.js +++ b/next.config.js @@ -1,14 +1,19 @@ const { i18n } = require('./next-i18next.config'); +const removeImports = require('next-remove-imports')(); + const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); -module.exports = withBundleAnalyzer({ - images: { - domains: ['cdn.jsdelivr.net'], - }, - reactStrictMode: true, - output: 'standalone', - i18n, -}); +module.exports = withBundleAnalyzer( + removeImports({ + experimental: { esmExternals: true }, + images: { + domains: ['cdn.jsdelivr.net'], + }, + reactStrictMode: true, + output: 'standalone', + i18n, + }) +); diff --git a/package.json b/package.json index 23e874fde..6a5469457 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@tabler/icons": "^1.106.0", "@tanstack/react-query": "^4.2.1", "@tanstack/react-query-devtools": "^4.24.4", + "@uiw/react-textarea-code-editor": "v1.4.4", "axios": "^0.27.2", "consola": "^2.15.3", "cookies-next": "^2.1.1", @@ -59,6 +60,7 @@ "js-file-download": "^0.4.12", "next": "^13.1.6", "next-i18next": "^11.3.0", + "next-remove-imports": "^1.0.8", "nzbget-api": "^0.0.3", "ping": "^0.4.2", "prism-react-renderer": "^1.3.5", @@ -81,6 +83,8 @@ "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/parser": "^5.30.7", + "babel-loader": "^9.1.2", + "babel-plugin-transform-remove-imports": "^1.7.0", "eslint": "^8.20.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 5c14a9ca6..1b78b10a3 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -24,5 +24,11 @@ "seconds": "seconds", "minutes": "minutes", "hours": "hours" + }, + "loading": "Loading...", + "breakPoints": { + "small": "small", + "medium": "medium", + "large": "large" } } \ No newline at end of file diff --git a/public/locales/en/layout/header/actions/toggle-edit-mode.json b/public/locales/en/layout/header/actions/toggle-edit-mode.json index 965eb3b8a..e99d62915 100644 --- a/public/locales/en/layout/header/actions/toggle-edit-mode.json +++ b/public/locales/en/layout/header/actions/toggle-edit-mode.json @@ -7,10 +7,5 @@ "popover": { "title": "Edit mode is enabled for <1>{{size}} size", "text": "You can adjust and configure your apps now. Changes are not saved until you exit edit mode" - }, - "screenSizes": { - "small": "small", - "medium": "medium", - "large": "large" } } diff --git a/public/locales/en/layout/modals/about.json b/public/locales/en/layout/modals/about.json index fa0044a91..2263dc21f 100644 --- a/public/locales/en/layout/modals/about.json +++ b/public/locales/en/layout/modals/about.json @@ -1,7 +1,13 @@ { "description": "Homarr is a sleek, modern dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one convenient location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.", - "i18n": "Loaded I18n translation namespaces", - "locales": "Configured I18n locales", "contact": "Having trouble or questions? Connect with us!", - "addToDashboard": "Add to Dashboard" -} + "addToDashboard": "Add to Dashboard", + "metrics": { + "configurationSchemaVersion": "Configuration schema version", + "configurationsCount": "Available configurations", + "version": "Version", + "nodeEnvironment": "Node environment", + "i18n": "Loaded I18n translation namespaces", + "locales": "Configured I18n locales" + } +} \ No newline at end of file diff --git a/public/locales/en/settings/common.json b/public/locales/en/settings/common.json index 7908b2be8..b5b0a488c 100644 --- a/public/locales/en/settings/common.json +++ b/public/locales/en/settings/common.json @@ -18,7 +18,11 @@ }, "grow": "Grow grid (take all space)", "layout": { - "title": "Dashboard layout", + "preview": { + "title": "Preview", + "subtitle": "Changes will be saved automatically" + }, + "divider": "Layout options", "main": "Main", "sidebar": "Sidebar", "cannotturnoff": "Cannot be turned off", diff --git a/public/locales/en/settings/customization/color-selector.json b/public/locales/en/settings/customization/color-selector.json index d66bbfe6e..19f42e95b 100644 --- a/public/locales/en/settings/customization/color-selector.json +++ b/public/locales/en/settings/customization/color-selector.json @@ -1,3 +1,4 @@ { + "colors": "Colors", "suffix": "{{color}} color" } \ No newline at end of file diff --git a/public/locales/en/settings/customization/general.json b/public/locales/en/settings/customization/general.json new file mode 100644 index 000000000..0215ba5e8 --- /dev/null +++ b/public/locales/en/settings/customization/general.json @@ -0,0 +1,21 @@ +{ + "text": "Customizations allow you to configure and adjust your experience with Homarr to your preferences.", + "accordeon": { + "layout": { + "name": "Layout", + "description": "Enable and disable elements on your header and dashboard tiles" + }, + "gridstack": { + "name": "Gridstack", + "description": "Customize the behaviour and columns of your dashboard area" + }, + "pageMetadata": { + "name": "Page Metadata", + "description": "Adjust titles, logo and PWA" + }, + "appereance": { + "name": "Appereance", + "description": "Customize the background, colors and apps appereance" + } + } +} \ No newline at end of file diff --git a/public/locales/en/settings/customization/gridstack.json b/public/locales/en/settings/customization/gridstack.json new file mode 100644 index 000000000..b98d62960 --- /dev/null +++ b/public/locales/en/settings/customization/gridstack.json @@ -0,0 +1,10 @@ +{ + "columnsCount": { + "labelPreset": "Columns in {{size}} size", + "descriptionPreset": "Number of columns when the screen is less than {{pixels}} pixels wide", + "descriptionExceedsPreset": "Number of columns when the screen size exceeds {{pixels}} pixels" + }, + "unsavedChanges": "You have unsaved changes. Click the Apply changes button below to apply and save.", + "applyChanges": "Apply changes", + "defaultValues": "Default values" +} \ No newline at end of file diff --git a/public/locales/en/settings/customization/page-appearance.json b/public/locales/en/settings/customization/page-appearance.json index 459154ceb..92c9dc632 100644 --- a/public/locales/en/settings/customization/page-appearance.json +++ b/public/locales/en/settings/customization/page-appearance.json @@ -1,22 +1,28 @@ { "pageTitle": { - "label": "Page Title" + "label": "Page Title", + "description": "The dashboard title at the top left" }, "metaTitle": { - "label": "Meta Title" + "label": "Meta Title", + "description": "The title, that is being displayed as your tab name" }, "logo": { - "label": "Logo" + "label": "Logo", + "description": "The dashboard logo at the top left" }, "favicon": { - "label": "Favicon" + "label": "Favicon", + "description": "The icon, that is being used in front of your tab name" }, "background": { "label": "Background" }, "customCSS": { "label": "Custom CSS", - "placeholder": "Custom CSS will be applied last" + "description": "Customize all elements on your dashboard, only recommended for experienced users", + "placeholder": "Custom CSS will be applied last", + "applying": "Applying CSS..." }, "buttons": { "submit": "Submit" diff --git a/src/components/Dashboard/Modals/AboutModal/AboutModal.tsx b/src/components/Dashboard/Modals/AboutModal/AboutModal.tsx index b703f82a6..3d36ca722 100644 --- a/src/components/Dashboard/Modals/AboutModal/AboutModal.tsx +++ b/src/components/Dashboard/Modals/AboutModal/AboutModal.tsx @@ -82,7 +82,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod {item.icon} - {t(item.label)} + {t(`layout/modals/about:metrics.${item.label}`)} {item.content} @@ -151,7 +151,6 @@ interface ExtendedInitOptions extends InitOptions { } const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => { - // TODO: Fix this to not request. Pass it as a prop. const colorGradiant = usePrimaryGradient(); const { attributes } = usePackageAttributesStore(); @@ -168,7 +167,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl ...items, { icon: , - label: 'layout/modals/about:i18n', + label: 'i18n', content: ( {usedI18nNamespaces.length} @@ -177,7 +176,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl }, { icon: , - label: 'layout/modals/about:locales', + label: 'locales', content: ( {initOptions.locales.length} @@ -190,7 +189,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl items = [ { icon: , - label: 'Configuration schema version', + label: 'configurationSchemaVersion', content: ( {configVersion} @@ -199,7 +198,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl }, { icon: , - label: 'Available configurations', + label: 'configurationsCount', content: ( {configs.length} @@ -249,7 +248,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl }, { icon: , - label: 'Node environment', + label: 'nodeEnvironment', content: ( {attributes.environment} diff --git a/src/components/Dashboard/Wrappers/gridstack/store.tsx b/src/components/Dashboard/Wrappers/gridstack/store.tsx index 6df8c2f75..084d9de75 100644 --- a/src/components/Dashboard/Wrappers/gridstack/store.tsx +++ b/src/components/Dashboard/Wrappers/gridstack/store.tsx @@ -1,5 +1,7 @@ import { useMantineTheme } from '@mantine/core'; import create from 'zustand'; +import { useConfigContext } from '../../../../config/provider'; +import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints'; export const useGridstackStore = create((set, get) => ({ mainAreaWidth: null, @@ -27,18 +29,28 @@ export const useNamedWrapperColumnCount = (): 'small' | 'medium' | 'large' | nul }; export const useWrapperColumnCount = () => { + const { config } = useConfigContext(); + + if (!config) { + return null; + } + switch (useNamedWrapperColumnCount()) { case 'large': - return 12; + return config.settings.customization.gridstack?.columnCountLarge ?? 12; case 'medium': - return 6; + return config.settings.customization.gridstack?.columnCountMedium ?? 6; case 'small': - return 3; + return config.settings.customization.gridstack?.columnCountSmall ?? 3; default: return null; } }; function getCurrentShapeSize(size: number) { - return size >= 1400 ? 'lg' : size >= 768 ? 'md' : 'sm'; + return size >= GridstackBreakpoints.large + ? 'lg' + : size >= GridstackBreakpoints.medium + ? 'md' + : 'sm'; } diff --git a/src/components/Settings/Customization/CustomizationAccordeon.tsx b/src/components/Settings/Customization/CustomizationAccordeon.tsx new file mode 100644 index 000000000..5aab48bdc --- /dev/null +++ b/src/components/Settings/Customization/CustomizationAccordeon.tsx @@ -0,0 +1,117 @@ +import { Accordion, Grid, Group, Stack, Text } from '@mantine/core'; +import { IconBrush, IconChartCandle, IconDragDrop, IconLayout } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { ReactNode } from 'react'; +import { GridstackConfiguration } from './Layout/GridstackConfiguration'; +import { LayoutSelector } from './Layout/LayoutSelector'; +import { BackgroundChanger } from './Meta/BackgroundChanger'; +import { FaviconChanger } from './Meta/FaviconChanger'; +import { LogoImageChanger } from './Meta/LogoImageChanger'; +import { BrowserTabTitle } from './Meta/MetaTitleChanger'; +import { DashboardTitleChanger } from './Meta/PageTitleChanger'; +import { ColorSelector } from './Theme/ColorSelector'; +import { CustomCssChanger } from './Theme/CustomCssChanger'; +import { DashboardTilesOpacitySelector } from './Theme/OpacitySelector'; +import { ShadeSelector } from './Theme/ShadeSelector'; + +export const CustomizationSettingsAccordeon = () => { + const items = getItems().map((item) => ( + + + + + + {item.content} + + + )); + return ( + + {items} + + ); +}; + +interface AccordionLabelProps { + label: string; + image: ReactNode; + description: string; +} + +const AccordionLabel = ({ label, image, description }: AccordionLabelProps) => ( + + {image} +
+ {label} + + {description} + +
+
+); + +const getItems = () => { + const { t } = useTranslation([ + 'settings/customization/general', + 'settings/customization/color-selector', + ]); + return [ + { + id: 'layout', + image: , + label: t('accordeon.layout.name'), + description: t('accordeon.layout.description'), + content: , + }, + { + id: 'gridstack', + image: , + label: t('accordeon.gridstack.name'), + description: t('accordeon.gridstack.description'), + content: , + }, + { + id: 'page_metadata', + image: , + label: t('accordeon.pageMetadata.name'), + description: t('accordeon.pageMetadata.description'), + content: ( + <> + + + + + + ), + }, + { + id: 'appereance', + image: , + label: t('accordeon.appereance.name'), + description: t('accordeon.appereance.description'), + content: ( + <> + + + + {t('settings/customization/color-selector:colors')} + + + + + + + + + + + + + + + + + ), + }, + ]; +}; diff --git a/src/components/Settings/Customization/CustomizationSettings.tsx b/src/components/Settings/Customization/CustomizationSettings.tsx index 8265ac4a6..f7dbc327d 100644 --- a/src/components/Settings/Customization/CustomizationSettings.tsx +++ b/src/components/Settings/Customization/CustomizationSettings.tsx @@ -1,46 +1,19 @@ -import { ScrollArea, Stack } from '@mantine/core'; +import { ScrollArea, Stack, Text } from '@mantine/core'; import { useViewportSize } from '@mantine/hooks'; import { useTranslation } from 'next-i18next'; -import { useConfigContext } from '../../../config/provider'; -import { useConfigStore } from '../../../config/store'; -import { LayoutSelector } from './Layout/LayoutSelector'; -import { BackgroundChanger } from './Meta/BackgroundChanger'; -import { FaviconChanger } from './Meta/FaviconChanger'; -import { LogoImageChanger } from './Meta/LogoImageChanger'; -import { MetaTitleChanger } from './Meta/MetaTitleChanger'; -import { PageTitleChanger } from './Meta/PageTitleChanger'; -import { ColorSelector } from './Theme/ColorSelector'; -import { CustomCssChanger } from './Theme/CustomCssChanger'; -import { OpacitySelector } from './Theme/OpacitySelector'; -import { ShadeSelector } from './Theme/ShadeSelector'; +import { CustomizationSettingsAccordeon } from './CustomizationAccordeon'; export default function CustomizationSettings() { - const { config, name: configName } = useConfigContext(); - const { t } = useTranslation('common'); - const { height, width } = useViewportSize(); - - const { updateConfig } = useConfigStore(); + const { height } = useViewportSize(); + const { t } = useTranslation('settings/customization/general'); return ( - - - - - - - - - - - + + {t('text')} + + ); diff --git a/src/components/Settings/Customization/Layout/GridstackConfiguration.tsx b/src/components/Settings/Customization/Layout/GridstackConfiguration.tsx new file mode 100644 index 000000000..8fe1d1b46 --- /dev/null +++ b/src/components/Settings/Customization/Layout/GridstackConfiguration.tsx @@ -0,0 +1,112 @@ +import { Alert, Button, Grid, Input, LoadingOverlay, Slider } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { IconCheck, IconReload } from '@tabler/icons'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import { useConfigContext } from '../../../../config/provider'; +import { useConfigStore } from '../../../../config/store'; +import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints'; +import { sleep } from '../../../../tools/client/time'; +import { GridstackSettingsType } from '../../../../types/settings'; + +export const GridstackConfiguration = () => { + const { t } = useTranslation(['settings/customization/gridstack', 'common']); + const { config, name: configName } = useConfigContext(); + const updateConfig = useConfigStore((x) => x.updateConfig); + + if (!config || !configName) { + return null; + } + + const initialValue = config.settings.customization?.gridstack ?? { + columnCountSmall: 3, + columnCountMedium: 6, + columnCountLarge: 12, + }; + + const form = useForm({ + initialValues: initialValue, + }); + + const [isSaving, setIsSaving] = useState(false); + + const handleSubmit = async (values: GridstackSettingsType) => { + setIsSaving(true); + + await sleep(250); + await updateConfig( + configName, + (previousConfig) => ({ + ...previousConfig, + settings: { + ...previousConfig.settings, + customization: { + ...previousConfig.settings.customization, + gridstack: values, + }, + }, + }), + true, + true + ); + + form.resetDirty(); + setIsSaving(false); + }; + + return ( +
+ + + + + + + + + + + {form.isDirty() && ( + + {t('unsavedChanges')} + + )} + + + + + + + + + + ); +}; diff --git a/src/components/Settings/Customization/Layout/LayoutSelector.tsx b/src/components/Settings/Customization/Layout/LayoutSelector.tsx index 94e380827..3b05524eb 100644 --- a/src/components/Settings/Customization/Layout/LayoutSelector.tsx +++ b/src/components/Settings/Customization/Layout/LayoutSelector.tsx @@ -1,43 +1,39 @@ import { - ActionIcon, Checkbox, createStyles, + Divider, Flex, Group, + Indicator, Paper, Stack, Text, Title, - useMantineTheme, } from '@mantine/core'; import { useTranslation } from 'next-i18next'; import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; +import { createDummyArray } from '../../../../tools/client/arrays'; import { CustomizationSettingsType } from '../../../../types/settings'; import { Logo } from '../../../layout/Logo'; -interface LayoutSelectorProps { - defaultLayout: CustomizationSettingsType['layout'] | undefined; -} - -// TODO: add translations -export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => { +export const LayoutSelector = () => { const { classes } = useStyles(); - const { name: configName } = useConfigContext(); + const { config, name: configName } = useConfigContext(); const updateConfig = useConfigStore((x) => x.updateConfig); - const [leftSidebar, setLeftSidebar] = useState(defaultLayout?.enabledLeftSidebar ?? true); - const [rightSidebar, setRightSidebar] = useState(defaultLayout?.enabledRightSidebar ?? true); - const [docker, setDocker] = useState(defaultLayout?.enabledDocker ?? false); - const [ping, setPing] = useState(defaultLayout?.enabledPing ?? false); - const [searchBar, setSearchBar] = useState(defaultLayout?.enabledSearchbar ?? false); + const layoutSettings = config?.settings.customization.layout; - const { colors, colorScheme } = useMantineTheme(); + const [leftSidebar, setLeftSidebar] = useState(layoutSettings?.enabledLeftSidebar ?? true); + const [rightSidebar, setRightSidebar] = useState(layoutSettings?.enabledRightSidebar ?? true); + const [docker, setDocker] = useState(layoutSettings?.enabledDocker ?? false); + const [ping, setPing] = useState(layoutSettings?.enabledPing ?? false); + const [searchBar, setSearchBar] = useState(layoutSettings?.enabledSearchbar ?? false); const { t } = useTranslation('settings/common'); - if (!configName) return null; + if (!configName || !config) return null; const handleChange = ( key: keyof CustomizationSettingsType['layout'], @@ -68,99 +64,140 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => { ); }; + const enabledPing = layoutSettings?.enabledPing ?? false; + return ( - - {t('layout.title')} - - - - - - {searchBar ? ( - - ) : null} - {docker ? : null} + <> + + {t('layout.preview.title')} + + {t('layout.preview.subtitle')} + + + + + + + + {searchBar && } + {docker && } + - - - - - {leftSidebar && ( - - - {t('layout.sidebar')} - - Only for -
- apps &
- integrations -
-
-
- )} - - - {t('layout.main')} - - {t('layout.cannotturnoff')} - - {rightSidebar && ( - - - {t('layout.sidebar')} - - Only for -
- apps &
- integrations -
+ + {leftSidebar && ( + + + {createDummyArray(5).map((item, index) => ( + + ))} + + + )} + + + + {createDummyArray(10).map((item, index) => ( + + ))} - )} -
- - handleChange('enabledLeftSidebar', ev, setLeftSidebar)} - /> - handleChange('enabledRightSidebar', ev, setRightSidebar)} - /> - handleChange('enabledSearchbar', ev, setSearchBar)} - /> - handleChange('enabledDocker', ev, setDocker)} - /> - handleChange('enabledPing', ev, setPing)} - /> + {rightSidebar && ( + + + {createDummyArray(5).map((item, index) => ( + + ))} + + + )} + + + + + handleChange('enabledLeftSidebar', ev, setLeftSidebar)} + /> + handleChange('enabledRightSidebar', ev, setRightSidebar)} + /> + handleChange('enabledSearchbar', ev, setSearchBar)} + /> + handleChange('enabledDocker', ev, setDocker)} + /> + handleChange('enabledPing', ev, setPing)} + /> + -
+ ); }; +const BaseElement = ({ height, width }: { height: number; width: number }) => ( + ({ + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.gray[8] : theme.colors.gray[1], + })} + h={height} + p={2} + w={width} + /> +); + +const PlaceholderElement = (props: any) => { + const { height, width, hasPing, index } = props; + + if (hasPing) { + return ( + + + + ); + } + + return ; +}; + const useStyles = createStyles((theme) => ({ primaryWrapper: { flexGrow: 2, diff --git a/src/components/Settings/Customization/Meta/BackgroundChanger.tsx b/src/components/Settings/Customization/Meta/BackgroundChanger.tsx index 5c3efabd2..3bbad0908 100644 --- a/src/components/Settings/Customization/Meta/BackgroundChanger.tsx +++ b/src/components/Settings/Customization/Meta/BackgroundChanger.tsx @@ -4,15 +4,13 @@ import { ChangeEventHandler, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; -interface BackgroundChangerProps { - defaultValue: string | undefined; -} - -export const BackgroundChanger = ({ defaultValue }: BackgroundChangerProps) => { +export const BackgroundChanger = () => { const { t } = useTranslation('settings/customization/page-appearance'); const updateConfig = useConfigStore((x) => x.updateConfig); - const { name: configName } = useConfigContext(); - const [backgroundImageUrl, setBackgroundImageUrl] = useState(defaultValue); + const { config, name: configName } = useConfigContext(); + const [backgroundImageUrl, setBackgroundImageUrl] = useState( + config?.settings.customization.backgroundImageUrl + ); if (!configName) return null; diff --git a/src/components/Settings/Customization/Meta/FaviconChanger.tsx b/src/components/Settings/Customization/Meta/FaviconChanger.tsx index 7bb71f815..f92252244 100644 --- a/src/components/Settings/Customization/Meta/FaviconChanger.tsx +++ b/src/components/Settings/Customization/Meta/FaviconChanger.tsx @@ -4,21 +4,19 @@ import { ChangeEventHandler, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; -interface FaviconChangerProps { - defaultValue: string | undefined; -} - -export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => { +export const FaviconChanger = () => { const { t } = useTranslation('settings/customization/page-appearance'); const updateConfig = useConfigStore((x) => x.updateConfig); - const { name: configName } = useConfigContext(); - const [faviconUrl, setFaviconUrl] = useState(defaultValue); + const { config, name: configName } = useConfigContext(); + const [faviconUrl, setFaviconUrl] = useState( + config?.settings.customization.faviconUrl ?? '/imgs/favicon/favicon-squared.png' + ); if (!configName) return null; const handleChange: ChangeEventHandler = (ev) => { const { value } = ev.currentTarget; - const faviconUrl = value.trim().length === 0 ? undefined : value; + const faviconUrl = value.trim(); setFaviconUrl(faviconUrl); updateConfig(configName, (prev) => ({ ...prev, @@ -35,6 +33,7 @@ export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => { return ( { +export const LogoImageChanger = () => { const { t } = useTranslation('settings/customization/page-appearance'); const updateConfig = useConfigStore((x) => x.updateConfig); - const { name: configName } = useConfigContext(); - const [logoImageSrc, setLogoImageSrc] = useState(defaultValue); + const { config, name: configName } = useConfigContext(); + const [logoImageSrc, setLogoImageSrc] = useState(config?.settings.customization.logoImageUrl ?? '/imgs/logo/logo.png'); if (!configName) return null; const handleChange: ChangeEventHandler = (ev) => { const { value } = ev.currentTarget; - const logoImageSrc = value.trim().length === 0 ? undefined : value; + const logoImageSrc = value.trim(); setLogoImageSrc(logoImageSrc); updateConfig(configName, (prev) => ({ ...prev, @@ -35,9 +31,11 @@ export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => { return ( ); }; diff --git a/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx b/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx index 3e871a9b8..d9e0b440f 100644 --- a/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx +++ b/src/components/Settings/Customization/Meta/MetaTitleChanger.tsx @@ -4,22 +4,17 @@ import { ChangeEventHandler, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; -interface MetaTitleChangerProps { - defaultValue: string | undefined; -} - -// TODO: change to pageTitle -export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => { +export const BrowserTabTitle = () => { const { t } = useTranslation('settings/customization/page-appearance'); const updateConfig = useConfigStore((x) => x.updateConfig); - const { name: configName } = useConfigContext(); - const [metaTitle, setMetaTitle] = useState(defaultValue); + const { config, name: configName } = useConfigContext(); + const [metaTitle, setMetaTitle] = useState(config?.settings.customization.metaTitle ?? ''); if (!configName) return null; const handleChange: ChangeEventHandler = (ev) => { const { value } = ev.currentTarget; - const metaTitle = value.trim().length === 0 ? undefined : value; + const metaTitle = value.trim(); setMetaTitle(metaTitle); updateConfig(configName, (prev) => ({ ...prev, @@ -36,9 +31,11 @@ export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => { return ( ); }; diff --git a/src/components/Settings/Customization/Meta/PageTitleChanger.tsx b/src/components/Settings/Customization/Meta/PageTitleChanger.tsx index c2febd0ed..eb1609a99 100644 --- a/src/components/Settings/Customization/Meta/PageTitleChanger.tsx +++ b/src/components/Settings/Customization/Meta/PageTitleChanger.tsx @@ -4,22 +4,17 @@ import { ChangeEventHandler, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; -interface PageTitleChangerProps { - defaultValue: string | undefined; -} - -// TODO: change to dashboard title -export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => { +export const DashboardTitleChanger = () => { const { t } = useTranslation('settings/customization/page-appearance'); const updateConfig = useConfigStore((x) => x.updateConfig); - const { name: configName } = useConfigContext(); - const [pageTitle, setPageTitle] = useState(defaultValue); + const { config, name: configName } = useConfigContext(); + const [pageTitle, setPageTitle] = useState(config?.settings.customization.pageTitle ?? ''); if (!configName) return null; const handleChange: ChangeEventHandler = (ev) => { const { value } = ev.currentTarget; - const pageTitle = value.trim().length === 0 ? undefined : value; + const pageTitle = value.trim(); setPageTitle(pageTitle); updateConfig(configName, (prev) => ({ ...prev, @@ -36,9 +31,11 @@ export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => { return ( ); }; diff --git a/src/components/Settings/Customization/Theme/ColorSelector.tsx b/src/components/Settings/Customization/Theme/ColorSelector.tsx index 053a7d386..a8725e9c7 100644 --- a/src/components/Settings/Customization/Theme/ColorSelector.tsx +++ b/src/components/Settings/Customization/Theme/ColorSelector.tsx @@ -24,7 +24,7 @@ export function ColorSelector({ type, defaultValue }: ColorControlProps) { const [color, setColor] = useState(defaultValue); const [popoverOpened, popover] = useDisclosure(false); const { setPrimaryColor, setSecondaryColor } = useColorTheme(); - const { name: configName } = useConfigContext(); + const { config, name: configName } = useConfigContext(); const updateConfig = useConfigStore((x) => x.updateConfig); const theme = useMantineTheme(); diff --git a/src/components/Settings/Customization/Theme/CustomCssChanger.tsx b/src/components/Settings/Customization/Theme/CustomCssChanger.tsx index 5b8db8de1..97155a688 100644 --- a/src/components/Settings/Customization/Theme/CustomCssChanger.tsx +++ b/src/components/Settings/Customization/Theme/CustomCssChanger.tsx @@ -1,44 +1,105 @@ -import { Textarea } from '@mantine/core'; +import { + Box, + createStyles, + Group, + Loader, + ScrollArea, + Stack, + Text, + useMantineTheme, +} from '@mantine/core'; +import { useDebouncedValue } from '@mantine/hooks'; import { useTranslation } from 'next-i18next'; -import { ChangeEventHandler, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { ChangeEvent, useEffect, useState } from 'react'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; -interface CustomCssChangerProps { - defaultValue: string | undefined; -} +const CodeEditor = dynamic( + () => import('@uiw/react-textarea-code-editor').then((mod) => mod.default), + { ssr: false } +); -export const CustomCssChanger = ({ defaultValue }: CustomCssChangerProps) => { +export const CustomCssChanger = () => { const { t } = useTranslation('settings/customization/page-appearance'); const updateConfig = useConfigStore((x) => x.updateConfig); - const { name: configName } = useConfigContext(); - const [customCss, setCustomCss] = useState(defaultValue); + const { colorScheme, colors } = useMantineTheme(); + const { config, name: configName } = useConfigContext(); + const [nonDebouncedCustomCSS, setNonDebouncedCustomCSS] = useState( + config?.settings.customization.customCss ?? '' + ); + const [debouncedCustomCSS] = useDebouncedValue(nonDebouncedCustomCSS, 696); + const { classes } = useStyles(); if (!configName) return null; - const handleChange: ChangeEventHandler = (ev) => { - const { value } = ev.currentTarget; - const customCss = value.trim().length === 0 ? undefined : value; - setCustomCss(customCss); + useEffect(() => { updateConfig(configName, (prev) => ({ ...prev, settings: { ...prev.settings, customization: { ...prev.settings.customization, - customCss, + customCss: debouncedCustomCSS, }, }, })); - }; + }, [debouncedCustomCSS]); + + const codeIsDirty = nonDebouncedCustomCSS !== debouncedCustomCSS; + const codeEditorHeight = codeIsDirty ? 250 - 42 : 250; return ( -