Custom column counts for gridstack #613 #660

This commit is contained in:
Manuel
2023-02-05 17:16:03 +01:00
committed by GitHub
parent 5296ce88d2
commit 2539e8cec1
37 changed files with 2064 additions and 745 deletions

View File

@@ -82,7 +82,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
<ActionIcon className={classes.informationIcon} variant="default">
{item.icon}
</ActionIcon>
{t(item.label)}
{t(`layout/modals/about:metrics.${item.label}`)}
</Group>
</td>
<td className={classes.informationTableColumn}>{item.content}</td>
@@ -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: <IconLanguage size={20} />,
label: 'layout/modals/about:i18n',
label: 'i18n',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{usedI18nNamespaces.length}
@@ -177,7 +176,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
},
{
icon: <IconVocabulary size={20} />,
label: 'layout/modals/about:locales',
label: 'locales',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{initOptions.locales.length}
@@ -190,7 +189,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
items = [
{
icon: <IconSchema size={20} />,
label: 'Configuration schema version',
label: 'configurationSchemaVersion',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{configVersion}
@@ -199,7 +198,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
},
{
icon: <IconFile size={20} />,
label: 'Available configurations',
label: 'configurationsCount',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{configs.length}
@@ -249,7 +248,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
},
{
icon: <IconAnchor size={20} />,
label: 'Node environment',
label: 'nodeEnvironment',
content: (
<Badge variant="gradient" gradient={colorGradiant}>
{attributes.environment}

View File

@@ -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<GridstackStoreType>((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';
}

View File

@@ -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) => (
<Accordion.Item value={item.id} key={item.label}>
<Accordion.Control>
<AccordionLabel {...item} />
</Accordion.Control>
<Accordion.Panel>
<Text size="sm">{item.content}</Text>
</Accordion.Panel>
</Accordion.Item>
));
return (
<Accordion variant="contained" chevronPosition="right">
{items}
</Accordion>
);
};
interface AccordionLabelProps {
label: string;
image: ReactNode;
description: string;
}
const AccordionLabel = ({ label, image, description }: AccordionLabelProps) => (
<Group noWrap>
{image}
<div>
<Text>{label}</Text>
<Text size="sm" color="dimmed" weight={400}>
{description}
</Text>
</div>
</Group>
);
const getItems = () => {
const { t } = useTranslation([
'settings/customization/general',
'settings/customization/color-selector',
]);
return [
{
id: 'layout',
image: <IconLayout />,
label: t('accordeon.layout.name'),
description: t('accordeon.layout.description'),
content: <LayoutSelector />,
},
{
id: 'gridstack',
image: <IconDragDrop />,
label: t('accordeon.gridstack.name'),
description: t('accordeon.gridstack.description'),
content: <GridstackConfiguration />,
},
{
id: 'page_metadata',
image: <IconChartCandle />,
label: t('accordeon.pageMetadata.name'),
description: t('accordeon.pageMetadata.description'),
content: (
<>
<DashboardTitleChanger />
<BrowserTabTitle />
<LogoImageChanger />
<FaviconChanger />
</>
),
},
{
id: 'appereance',
image: <IconBrush />,
label: t('accordeon.appereance.name'),
description: t('accordeon.appereance.description'),
content: (
<>
<BackgroundChanger />
<Stack spacing="xs" my="md">
<Text>{t('settings/customization/color-selector:colors')}</Text>
<Grid>
<Grid.Col sm={12} md={6}>
<ColorSelector type="primary" defaultValue="red" />
</Grid.Col>
<Grid.Col sm={12} md={6}>
<ColorSelector type="secondary" defaultValue="orange" />
</Grid.Col>
<Grid.Col sm={12} md={6}>
<ShadeSelector />
</Grid.Col>
</Grid>
</Stack>
<DashboardTilesOpacitySelector />
<CustomCssChanger />
</>
),
},
];
};

View File

@@ -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 (
<ScrollArea style={{ height: height - 100 }} offsetScrollbars>
<Stack mt="xs" mb="md" spacing="xs">
<LayoutSelector defaultLayout={config?.settings.customization.layout} />
<PageTitleChanger defaultValue={config?.settings.customization.pageTitle} />
<MetaTitleChanger defaultValue={config?.settings.customization.metaTitle} />
<LogoImageChanger defaultValue={config?.settings.customization.logoImageUrl} />
<FaviconChanger defaultValue={config?.settings.customization.faviconUrl} />
<BackgroundChanger defaultValue={config?.settings.customization.backgroundImageUrl} />
<CustomCssChanger defaultValue={config?.settings.customization.customCss} />
<ColorSelector
type="primary"
defaultValue={config?.settings.customization.colors.primary}
/>
<ColorSelector
type="secondary"
defaultValue={config?.settings.customization.colors.secondary}
/>
<ShadeSelector defaultValue={config?.settings.customization.colors.shade} />
<OpacitySelector defaultValue={config?.settings.customization.appOpacity} />
<Text color="dimmed">
{t('text')}
</Text>
<CustomizationSettingsAccordeon />
</Stack>
</ScrollArea>
);

View File

@@ -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 onSubmit={form.onSubmit(handleSubmit)} style={{ position: 'relative' }}>
<LoadingOverlay overlayBlur={2} visible={isSaving} radius="md" />
<Input.Wrapper
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.small') })}
description={t('columnsCount.descriptionPreset', { pixels: GridstackBreakpoints.medium })}
mb="md"
>
<Slider min={1} max={8} mt="xs" {...form.getInputProps('columnCountSmall')} />
</Input.Wrapper>
<Input.Wrapper
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.medium') })}
description={t('columnsCount.descriptionPreset', { pixels: GridstackBreakpoints.large })}
mb="md"
>
<Slider min={3} max={16} mt="xs" {...form.getInputProps('columnCountMedium')} />
</Input.Wrapper>
<Input.Wrapper
label={t('columnsCount.labelPreset', { size: t('common:breakPoints.large') })}
description={t('columnsCount.descriptionExceedsPreset', {
pixels: GridstackBreakpoints.large,
})}
>
<Slider min={5} max={20} mt="xs" {...form.getInputProps('columnCountLarge')} />
</Input.Wrapper>
{form.isDirty() && (
<Alert variant="light" color="yellow" title="Unsaved changes" my="md">
{t('unsavedChanges')}
</Alert>
)}
<Grid mt="md">
<Grid.Col md={6} xs={12}>
<Button variant="light" leftIcon={<IconCheck size={18} />} type="submit" fullWidth>
{t('applyChanges')}
</Button>
</Grid.Col>
<Grid.Col md={6} xs={12}>
<Button
variant="light"
leftIcon={<IconReload size={18} />}
onClick={() =>
form.setValues({
columnCountSmall: 3,
columnCountMedium: 6,
columnCountLarge: 12,
})
}
fullWidth
>
{t('defaultValues')}
</Button>
</Grid.Col>
</Grid>
</form>
);
};

View File

@@ -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 (
<Stack spacing="xs">
<Title order={6}>{t('layout.title')}</Title>
<Paper px="xs" py={4} withBorder>
<Group position="apart">
<Logo size="xs" />
<Group spacing={5}>
{searchBar ? (
<Paper
style={{
height: 10,
backgroundColor: colorScheme === 'dark' ? colors.gray[8] : colors.gray[1],
}}
p={2}
w={60}
/>
) : null}
{docker ? <ActionIcon size={10} disabled /> : null}
<>
<Stack spacing={0} mb="md">
<Title order={6}>{t('layout.preview.title')}</Title>
<Text color="dimmed" size="xs">
{t('layout.preview.subtitle')}
</Text>
</Stack>
<Stack spacing="xs">
<Paper px="xs" py={4} withBorder>
<Group position="apart">
<Logo size="xs" />
<Group spacing={5}>
{searchBar && <PlaceholderElement width={60} height={10} />}
{docker && <PlaceholderElement width={10} height={10} />}
</Group>
</Group>
</Group>
</Paper>
<Group align="stretch">
{leftSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex align="center" justify="center" direction="column">
<Text align="center">{t('layout.sidebar')}</Text>
<Text color="dimmed" size="xs" align="center">
Only for
<br />
apps &<br />
integrations
</Text>
</Flex>
</Paper>
)}
<Paper className={classes.primaryWrapper} p="xs" withBorder>
<Text align="center">{t('layout.main')}</Text>
<Text color="dimmed" size="xs" align="center">
{t('layout.cannotturnoff')}
</Text>
</Paper>
{rightSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex align="center" justify="center" direction="column">
<Text align="center">{t('layout.sidebar')}</Text>
<Text color="dimmed" size="xs" align="center">
Only for
<br />
apps &<br />
integrations
</Text>
<Flex gap={6}>
{leftSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex gap={5} wrap="wrap">
{createDummyArray(5).map((item, index) => (
<PlaceholderElement
height={index % 4 === 0 ? 60 + 5 : 30}
width={30}
key={`example-item-right-sidebard-${index}`}
index={index}
hasPing={enabledPing}
/>
))}
</Flex>
</Paper>
)}
<Paper className={classes.primaryWrapper} p="xs" withBorder>
<Flex gap={5} wrap="wrap">
{createDummyArray(10).map((item, index) => (
<PlaceholderElement
height={30}
width={index % 5 === 0 ? 60 : 30}
key={`example-item-main-${index}`}
index={index}
hasPing={enabledPing}
/>
))}
</Flex>
</Paper>
)}
</Group>
<Stack spacing="xs">
<Checkbox
label={t('layout.enablelsidebar')}
description={t('layout.enablelsidebardesc')}
checked={leftSidebar}
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
/>
<Checkbox
label={t('layout.enablersidebar')}
description={t('layout.enablersidebardesc')}
checked={rightSidebar}
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
/>
<Checkbox
label={t('layout.enablesearchbar')}
checked={searchBar}
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
/>
<Checkbox
label={t('layout.enabledocker')}
checked={docker}
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
/>
<Checkbox
label={t('layout.enableping')}
checked={ping}
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
/>
{rightSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex gap={5} align="start" wrap="wrap">
{createDummyArray(5).map((item, index) => (
<PlaceholderElement
height={30}
width={index % 4 === 0 ? 60 + 5 : 30}
key={`example-item-right-sidebard-${index}`}
index={index}
hasPing={enabledPing}
/>
))}
</Flex>
</Paper>
)}
</Flex>
<Divider label={t('layout.divider')} labelPosition="center" mt="md" mb="xs" />
<Stack spacing="xs">
<Checkbox
label={t('layout.enablelsidebar')}
description={t('layout.enablelsidebardesc')}
checked={leftSidebar}
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
/>
<Checkbox
label={t('layout.enablersidebar')}
description={t('layout.enablersidebardesc')}
checked={rightSidebar}
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
/>
<Checkbox
label={t('layout.enablesearchbar')}
checked={searchBar}
onChange={(ev) => handleChange('enabledSearchbar', ev, setSearchBar)}
/>
<Checkbox
label={t('layout.enabledocker')}
checked={docker}
onChange={(ev) => handleChange('enabledDocker', ev, setDocker)}
/>
<Checkbox
label={t('layout.enableping')}
checked={ping}
onChange={(ev) => handleChange('enabledPing', ev, setPing)}
/>
</Stack>
</Stack>
</Stack>
</>
);
};
const BaseElement = ({ height, width }: { height: number; width: number }) => (
<Paper
sx={(theme) => ({
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 (
<Indicator
position="bottom-end"
size={5}
offset={10}
color={index % 4 === 0 ? 'red' : 'green'}
>
<BaseElement width={width} height={height} />
</Indicator>
);
}
return <BaseElement width={width} height={height} />;
};
const useStyles = createStyles((theme) => ({
primaryWrapper: {
flexGrow: 2,

View File

@@ -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;

View File

@@ -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<HTMLInputElement> = (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 (
<TextInput
label={t('favicon.label')}
description={t('favicon.description')}
placeholder="/imgs/favicon/favicon.svg"
value={faviconUrl}
onChange={handleChange}

View File

@@ -4,21 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface LogoImageChangerProps {
defaultValue: string | undefined;
}
export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
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<HTMLInputElement> = (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 (
<TextInput
label={t('logo.label')}
description={t('logo.description')}
placeholder="/imgs/logo/logo.png"
value={logoImageSrc}
onChange={handleChange}
mb="sm"
/>
);
};

View File

@@ -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<HTMLInputElement> = (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 (
<TextInput
label={t('metaTitle.label')}
description={t('metaTitle.description')}
placeholder="homarr - the best dashboard"
value={metaTitle}
onChange={handleChange}
mb="sm"
/>
);
};

View File

@@ -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<HTMLInputElement> = (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 (
<TextInput
label={t('pageTitle.label')}
description={t('pageTitle.description')}
placeholder="homarr"
value={pageTitle}
onChange={handleChange}
mb="sm"
/>
);
};

View File

@@ -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();

View File

@@ -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<HTMLTextAreaElement> = (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 (
<Textarea
minRows={5}
label={t('customCSS.label')}
placeholder={t('customCSS.placeholder')}
value={customCss}
onChange={handleChange}
/>
<Stack spacing={4} mt="xl">
<Text>{t('customCSS.label')}</Text>
<Text color="dimmed" size="xs">
{t('customCSS.description')}
</Text>
<div className={classes.codeEditorRoot}>
<ScrollArea style={{ height: codeEditorHeight }}>
<CodeEditor
className={classes.codeEditor}
placeholder={t('customCSS.placeholder')}
value={nonDebouncedCustomCSS}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
setNonDebouncedCustomCSS(event.target.value.trim())
}
language="css"
data-color-mode={colorScheme}
minHeight={codeEditorHeight}
/>
</ScrollArea>
{codeIsDirty && (
<Box className={classes.codeEditorFooter}>
<Group p="xs" spacing="xs">
<Loader color={colors.gray[0]} size={18} />
<Text>{t('customCSS.applying')}</Text>
</Group>
</Box>
)}
</div>
</Stack>
);
};
const useStyles = createStyles(({ colors, colorScheme, radius }) => ({
codeEditorFooter: {
borderBottomLeftRadius: radius.sm,
borderBottomRightRadius: radius.sm,
backgroundColor: colorScheme === 'dark' ? colors.dark[7] : undefined,
},
codeEditorRoot: {
borderColor: colorScheme === 'dark' ? colors.dark[4] : colors.gray[4],
borderWidth: 1,
borderStyle: 'solid',
borderRadius: radius.sm,
},
codeEditor: {
backgroundColor: colorScheme === 'dark' ? colors.dark[6] : 'white',
fontSize: 12,
'& ::placeholder': {
color: colorScheme === 'dark' ? colors.dark[3] : colors.gray[5],
},
},
}));

View File

@@ -4,15 +4,11 @@ import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
interface OpacitySelectorProps {
defaultValue: number | undefined;
}
export function OpacitySelector({ defaultValue }: OpacitySelectorProps) {
const [opacity, setOpacity] = useState(defaultValue || 100);
export function DashboardTilesOpacitySelector() {
const { config, name: configName } = useConfigContext();
const [opacity, setOpacity] = useState(config?.settings.customization.appOpacity || 100);
const { t } = useTranslation('settings/customization/opacity-selector');
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
if (!configName) return null;

View File

@@ -15,17 +15,13 @@ import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useColorTheme } from '../../../../tools/color';
interface ShadeSelectorProps {
defaultValue: MantineTheme['primaryShade'] | undefined;
}
export function ShadeSelector({ defaultValue }: ShadeSelectorProps) {
export function ShadeSelector() {
const { t } = useTranslation('settings/customization/shade-selector');
const [shade, setShade] = useState(defaultValue);
const { config, name: configName } = useConfigContext();
const [shade, setShade] = useState(config?.settings.customization.colors.shade);
const [popoverOpened, popover] = useDisclosure(false);
const { primaryColor, setPrimaryShade } = useColorTheme();
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
const theme = useMantineTheme();

View File

@@ -17,8 +17,11 @@ import { useCardStyles } from '../../../useCardStyles';
export const ToggleEditModeAction = () => {
const { enabled, toggleEditMode } = useEditModeStore();
const namedWrapperColumnCount = useNamedWrapperColumnCount();
const { t } = useTranslation('layout/header/actions/toggle-edit-mode');
const translatedSize = t(`screenSizes.${namedWrapperColumnCount}`);
const { t } = useTranslation(['layout/header/actions/toggle-edit-mode', 'common']);
const translatedSize =
namedWrapperColumnCount !== null
? t(`common:breakPoints.${namedWrapperColumnCount}`)
: t('common:loading');
const smallerThanSm = useScreenSmallerThan('sm');
const { config } = useConfigContext();

View File

@@ -1,6 +1,5 @@
import { Box, createStyles, Group, Header as MantineHeader, Indicator } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { REPO_URL } from '../../../../data/constants';
import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { usePackageAttributesStore } from '../../../tools/client/zustands/usePackageAttributesStore';

View File

@@ -0,0 +1,4 @@
export const GridstackBreakpoints = {
large: 1400,
medium: 768,
};

View File

@@ -18,7 +18,6 @@ import { WidgetsEditModal } from '../components/Dashboard/Tiles/Widgets/WidgetsE
import { WidgetsRemoveModal } from '../components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/CategoryEditModal';
import { ConfigProvider } from '../config/provider';
import '../styles/global.scss';
import { ColorTheme } from '../tools/color';
import { queryClient } from '../tools/queryClient';
import { theme } from '../tools/theme';
@@ -28,6 +27,9 @@ import {
} from '../tools/server/getPackageVersion';
import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore';
import '../styles/global.scss';
import '@uiw/react-textarea-code-editor/dist.css';
function App(
this: any,
props: AppProps & { colorScheme: ColorScheme; packageAttributes: ServerSidePackageAttributesType }

View File

@@ -18,7 +18,7 @@
}
// Styling for grid-stack main area
@for $i from 1 to 13 {
@for $i from 1 to 21 {
.grid-stack>.grid-stack-item[gs-w="#{$i}"] { width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
.grid-stack>.grid-stack-item[gs-min-w="#{$i}"] { min-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
.grid-stack>.grid-stack-item[gs-max-w="#{$i}"] { max-width: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
@@ -30,7 +30,7 @@
.grid-stack>.grid-stack-item[gs-max-h="#{$i}"] { max-height: calc(#{$i}px * #{var(--gridstack-widget-width)}) }
}
@for $i from 1 to 13 {
@for $i from 1 to 21 {
.grid-stack>.grid-stack-item[gs-x="#{$i}"] { left: calc(100% / #{var(--gridstack-column-count)} * #{$i}) }
}
@@ -56,7 +56,7 @@
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-max-h="#{$i}"] { max-height: 128px * $i }
}
@for $i from 1 to 13 {
@for $i from 1 to 3 {
.grid-stack.grid-stack-sidebar>.grid-stack-item[gs-x="#{$i}"] { left: 128px * $i }
}
@@ -91,4 +91,4 @@
.gridstack-empty-wrapper {
height: 0px;
min-height: 0px !important;
}
}

View File

@@ -0,0 +1 @@
export const createDummyArray = (countItems: number) => Array.from(Array(countItems).keys());

4
src/tools/client/time.ts Normal file
View File

@@ -0,0 +1,4 @@
export const sleep = (ms: number) =>
new Promise((r) => {
setTimeout(r, ms);
});

View File

@@ -13,11 +13,13 @@ export const dashboardNamespaces = [
'settings/general/internationalization',
'settings/general/search-engine',
'settings/general/widget-positions',
'settings/customization/general',
'settings/customization/color-selector',
'settings/customization/page-appearance',
'settings/customization/shade-selector',
'settings/customization/app-width',
'settings/customization/opacity-selector',
'settings/customization/gridstack',
'modules/common',
'modules/date',
'modules/calendar',

View File

@@ -44,6 +44,13 @@ export interface CustomizationSettingsType {
customCss?: string;
colors: ColorsCustomizationSettingsType;
appOpacity?: number;
gridstack?: GridstackSettingsType;
}
export interface GridstackSettingsType {
columnCountSmall: number; // default: 3
columnCountMedium: number; // default: 6
columnCountLarge: number; // default: 12
}
interface LayoutCustomizationSettingsType {

View File

@@ -3,7 +3,6 @@ import {
Box,
Card,
Group,
Indicator,
Stack,
Text,
Title,
@@ -41,7 +40,7 @@ const definition = defineWidget({
component: TorrentNetworkTrafficTile,
});
export type ITorrentNetworkTraffic = IWidget<typeof definition['id'], typeof definition>;
export type ITorrentNetworkTraffic = IWidget<(typeof definition)['id'], typeof definition>;
interface TorrentNetworkTrafficTileProps {
widget: ITorrentNetworkTraffic;

View File

@@ -1,4 +1,3 @@
import { TorrentState } from '@ctrl/shared-torrent';
import {
Badge,
Center,
@@ -52,7 +51,7 @@ const definition = defineWidget({
component: TorrentTile,
});
export type ITorrent = IWidget<typeof definition['id'], typeof definition>;
export type ITorrent = IWidget<(typeof definition)['id'], typeof definition>;
interface TorrentTileProps {
widget: ITorrent;