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

@@ -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,
})
);

View File

@@ -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",

View File

@@ -24,5 +24,11 @@
"seconds": "seconds",
"minutes": "minutes",
"hours": "hours"
},
"loading": "Loading...",
"breakPoints": {
"small": "small",
"medium": "medium",
"large": "large"
}
}

View File

@@ -7,10 +7,5 @@
"popover": {
"title": "Edit mode is enabled for <1>{{size}}</1> size",
"text": "You can adjust and configure your apps now. Changes are <strong>not saved</strong> until you exit edit mode"
},
"screenSizes": {
"small": "small",
"medium": "medium",
"large": "large"
}
}

View File

@@ -1,7 +1,13 @@
{
"description": "Homarr is a <strong>sleek</strong>, <strong>modern</strong> 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"
}
}

View File

@@ -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",

View File

@@ -1,3 +1,4 @@
{
"colors": "Colors",
"suffix": "{{color}} color"
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}

View File

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

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;

1927
yarn.lock

File diff suppressed because it is too large Load Diff