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 { i18n } = require('./next-i18next.config');
const removeImports = require('next-remove-imports')();
const withBundleAnalyzer = require('@next/bundle-analyzer')({ const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true', enabled: process.env.ANALYZE === 'true',
}); });
module.exports = withBundleAnalyzer({ module.exports = withBundleAnalyzer(
removeImports({
experimental: { esmExternals: true },
images: { images: {
domains: ['cdn.jsdelivr.net'], domains: ['cdn.jsdelivr.net'],
}, },
reactStrictMode: true, reactStrictMode: true,
output: 'standalone', output: 'standalone',
i18n, i18n,
}); })
);

View File

@@ -45,6 +45,7 @@
"@tabler/icons": "^1.106.0", "@tabler/icons": "^1.106.0",
"@tanstack/react-query": "^4.2.1", "@tanstack/react-query": "^4.2.1",
"@tanstack/react-query-devtools": "^4.24.4", "@tanstack/react-query-devtools": "^4.24.4",
"@uiw/react-textarea-code-editor": "v1.4.4",
"axios": "^0.27.2", "axios": "^0.27.2",
"consola": "^2.15.3", "consola": "^2.15.3",
"cookies-next": "^2.1.1", "cookies-next": "^2.1.1",
@@ -59,6 +60,7 @@
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"next": "^13.1.6", "next": "^13.1.6",
"next-i18next": "^11.3.0", "next-i18next": "^11.3.0",
"next-remove-imports": "^1.0.8",
"nzbget-api": "^0.0.3", "nzbget-api": "^0.0.3",
"ping": "^0.4.2", "ping": "^0.4.2",
"prism-react-renderer": "^1.3.5", "prism-react-renderer": "^1.3.5",
@@ -81,6 +83,8 @@
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.30.7", "@typescript-eslint/eslint-plugin": "^5.30.7",
"@typescript-eslint/parser": "^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": "^8.20.0",
"eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-airbnb-typescript": "^17.0.0",

View File

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

View File

@@ -7,10 +7,5 @@
"popover": { "popover": {
"title": "Edit mode is enabled for <1>{{size}}</1> size", "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" "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.", "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!", "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)", "grow": "Grow grid (take all space)",
"layout": { "layout": {
"title": "Dashboard layout", "preview": {
"title": "Preview",
"subtitle": "Changes will be saved automatically"
},
"divider": "Layout options",
"main": "Main", "main": "Main",
"sidebar": "Sidebar", "sidebar": "Sidebar",
"cannotturnoff": "Cannot be turned off", "cannotturnoff": "Cannot be turned off",

View File

@@ -1,3 +1,4 @@
{ {
"colors": "Colors",
"suffix": "{{color}} color" "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": { "pageTitle": {
"label": "Page Title" "label": "Page Title",
"description": "The dashboard title at the top left"
}, },
"metaTitle": { "metaTitle": {
"label": "Meta Title" "label": "Meta Title",
"description": "The title, that is being displayed as your tab name"
}, },
"logo": { "logo": {
"label": "Logo" "label": "Logo",
"description": "The dashboard logo at the top left"
}, },
"favicon": { "favicon": {
"label": "Favicon" "label": "Favicon",
"description": "The icon, that is being used in front of your tab name"
}, },
"background": { "background": {
"label": "Background" "label": "Background"
}, },
"customCSS": { "customCSS": {
"label": "Custom CSS", "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": { "buttons": {
"submit": "Submit" "submit": "Submit"

View File

@@ -82,7 +82,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
<ActionIcon className={classes.informationIcon} variant="default"> <ActionIcon className={classes.informationIcon} variant="default">
{item.icon} {item.icon}
</ActionIcon> </ActionIcon>
{t(item.label)} {t(`layout/modals/about:metrics.${item.label}`)}
</Group> </Group>
</td> </td>
<td className={classes.informationTableColumn}>{item.content}</td> <td className={classes.informationTableColumn}>{item.content}</td>
@@ -151,7 +151,6 @@ interface ExtendedInitOptions extends InitOptions {
} }
const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => { const useInformationTableItems = (newVersionAvailable?: string): InformationTableItem[] => {
// TODO: Fix this to not request. Pass it as a prop.
const colorGradiant = usePrimaryGradient(); const colorGradiant = usePrimaryGradient();
const { attributes } = usePackageAttributesStore(); const { attributes } = usePackageAttributesStore();
@@ -168,7 +167,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
...items, ...items,
{ {
icon: <IconLanguage size={20} />, icon: <IconLanguage size={20} />,
label: 'layout/modals/about:i18n', label: 'i18n',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="gradient" gradient={colorGradiant}>
{usedI18nNamespaces.length} {usedI18nNamespaces.length}
@@ -177,7 +176,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
}, },
{ {
icon: <IconVocabulary size={20} />, icon: <IconVocabulary size={20} />,
label: 'layout/modals/about:locales', label: 'locales',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="gradient" gradient={colorGradiant}>
{initOptions.locales.length} {initOptions.locales.length}
@@ -190,7 +189,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
items = [ items = [
{ {
icon: <IconSchema size={20} />, icon: <IconSchema size={20} />,
label: 'Configuration schema version', label: 'configurationSchemaVersion',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="gradient" gradient={colorGradiant}>
{configVersion} {configVersion}
@@ -199,7 +198,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
}, },
{ {
icon: <IconFile size={20} />, icon: <IconFile size={20} />,
label: 'Available configurations', label: 'configurationsCount',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="gradient" gradient={colorGradiant}>
{configs.length} {configs.length}
@@ -249,7 +248,7 @@ const useInformationTableItems = (newVersionAvailable?: string): InformationTabl
}, },
{ {
icon: <IconAnchor size={20} />, icon: <IconAnchor size={20} />,
label: 'Node environment', label: 'nodeEnvironment',
content: ( content: (
<Badge variant="gradient" gradient={colorGradiant}> <Badge variant="gradient" gradient={colorGradiant}>
{attributes.environment} {attributes.environment}

View File

@@ -1,5 +1,7 @@
import { useMantineTheme } from '@mantine/core'; import { useMantineTheme } from '@mantine/core';
import create from 'zustand'; import create from 'zustand';
import { useConfigContext } from '../../../../config/provider';
import { GridstackBreakpoints } from '../../../../constants/gridstack-breakpoints';
export const useGridstackStore = create<GridstackStoreType>((set, get) => ({ export const useGridstackStore = create<GridstackStoreType>((set, get) => ({
mainAreaWidth: null, mainAreaWidth: null,
@@ -27,18 +29,28 @@ export const useNamedWrapperColumnCount = (): 'small' | 'medium' | 'large' | nul
}; };
export const useWrapperColumnCount = () => { export const useWrapperColumnCount = () => {
const { config } = useConfigContext();
if (!config) {
return null;
}
switch (useNamedWrapperColumnCount()) { switch (useNamedWrapperColumnCount()) {
case 'large': case 'large':
return 12; return config.settings.customization.gridstack?.columnCountLarge ?? 12;
case 'medium': case 'medium':
return 6; return config.settings.customization.gridstack?.columnCountMedium ?? 6;
case 'small': case 'small':
return 3; return config.settings.customization.gridstack?.columnCountSmall ?? 3;
default: default:
return null; return null;
} }
}; };
function getCurrentShapeSize(size: number) { 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 { useViewportSize } from '@mantine/hooks';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider'; import { CustomizationSettingsAccordeon } from './CustomizationAccordeon';
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';
export default function CustomizationSettings() { export default function CustomizationSettings() {
const { config, name: configName } = useConfigContext(); const { height } = useViewportSize();
const { t } = useTranslation('common'); const { t } = useTranslation('settings/customization/general');
const { height, width } = useViewportSize();
const { updateConfig } = useConfigStore();
return ( return (
<ScrollArea style={{ height: height - 100 }} offsetScrollbars> <ScrollArea style={{ height: height - 100 }} offsetScrollbars>
<Stack mt="xs" mb="md" spacing="xs"> <Stack mt="xs" mb="md" spacing="xs">
<LayoutSelector defaultLayout={config?.settings.customization.layout} /> <Text color="dimmed">
<PageTitleChanger defaultValue={config?.settings.customization.pageTitle} /> {t('text')}
<MetaTitleChanger defaultValue={config?.settings.customization.metaTitle} /> </Text>
<LogoImageChanger defaultValue={config?.settings.customization.logoImageUrl} /> <CustomizationSettingsAccordeon />
<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} />
</Stack> </Stack>
</ScrollArea> </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 { import {
ActionIcon,
Checkbox, Checkbox,
createStyles, createStyles,
Divider,
Flex, Flex,
Group, Group,
Indicator,
Paper, Paper,
Stack, Stack,
Text, Text,
Title, Title,
useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react'; import { ChangeEvent, Dispatch, SetStateAction, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
import { createDummyArray } from '../../../../tools/client/arrays';
import { CustomizationSettingsType } from '../../../../types/settings'; import { CustomizationSettingsType } from '../../../../types/settings';
import { Logo } from '../../../layout/Logo'; import { Logo } from '../../../layout/Logo';
interface LayoutSelectorProps { export const LayoutSelector = () => {
defaultLayout: CustomizationSettingsType['layout'] | undefined;
}
// TODO: add translations
export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
const { classes } = useStyles(); const { classes } = useStyles();
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const [leftSidebar, setLeftSidebar] = useState(defaultLayout?.enabledLeftSidebar ?? true); const layoutSettings = config?.settings.customization.layout;
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 { 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'); const { t } = useTranslation('settings/common');
if (!configName) return null; if (!configName || !config) return null;
const handleChange = ( const handleChange = (
key: keyof CustomizationSettingsType['layout'], key: keyof CustomizationSettingsType['layout'],
@@ -68,66 +64,76 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
); );
}; };
return ( const enabledPing = layoutSettings?.enabledPing ?? false;
<Stack spacing="xs">
<Title order={6}>{t('layout.title')}</Title>
return (
<>
<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> <Paper px="xs" py={4} withBorder>
<Group position="apart"> <Group position="apart">
<Logo size="xs" /> <Logo size="xs" />
<Group spacing={5}> <Group spacing={5}>
{searchBar ? ( {searchBar && <PlaceholderElement width={60} height={10} />}
<Paper {docker && <PlaceholderElement width={10} height={10} />}
style={{
height: 10,
backgroundColor: colorScheme === 'dark' ? colors.gray[8] : colors.gray[1],
}}
p={2}
w={60}
/>
) : null}
{docker ? <ActionIcon size={10} disabled /> : null}
</Group> </Group>
</Group> </Group>
</Paper> </Paper>
<Group align="stretch"> <Flex gap={6}>
{leftSidebar && ( {leftSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder> <Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex align="center" justify="center" direction="column"> <Flex gap={5} wrap="wrap">
<Text align="center">{t('layout.sidebar')}</Text> {createDummyArray(5).map((item, index) => (
<Text color="dimmed" size="xs" align="center"> <PlaceholderElement
Only for height={index % 4 === 0 ? 60 + 5 : 30}
<br /> width={30}
apps &<br /> key={`example-item-right-sidebard-${index}`}
integrations index={index}
</Text> hasPing={enabledPing}
/>
))}
</Flex> </Flex>
</Paper> </Paper>
)} )}
<Paper className={classes.primaryWrapper} p="xs" withBorder> <Paper className={classes.primaryWrapper} p="xs" withBorder>
<Text align="center">{t('layout.main')}</Text> <Flex gap={5} wrap="wrap">
<Text color="dimmed" size="xs" align="center"> {createDummyArray(10).map((item, index) => (
{t('layout.cannotturnoff')} <PlaceholderElement
</Text> height={30}
width={index % 5 === 0 ? 60 : 30}
key={`example-item-main-${index}`}
index={index}
hasPing={enabledPing}
/>
))}
</Flex>
</Paper> </Paper>
{rightSidebar && ( {rightSidebar && (
<Paper className={classes.secondaryWrapper} p="xs" withBorder> <Paper className={classes.secondaryWrapper} p="xs" withBorder>
<Flex align="center" justify="center" direction="column"> <Flex gap={5} align="start" wrap="wrap">
<Text align="center">{t('layout.sidebar')}</Text> {createDummyArray(5).map((item, index) => (
<Text color="dimmed" size="xs" align="center"> <PlaceholderElement
Only for height={30}
<br /> width={index % 4 === 0 ? 60 + 5 : 30}
apps &<br /> key={`example-item-right-sidebard-${index}`}
integrations index={index}
</Text> hasPing={enabledPing}
/>
))}
</Flex> </Flex>
</Paper> </Paper>
)} )}
</Group> </Flex>
<Divider label={t('layout.divider')} labelPosition="center" mt="md" mb="xs" />
<Stack spacing="xs"> <Stack spacing="xs">
<Checkbox <Checkbox
label={t('layout.enablelsidebar')} label={t('layout.enablelsidebar')}
@@ -158,9 +164,40 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
/> />
</Stack> </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) => ({ const useStyles = createStyles((theme) => ({
primaryWrapper: { primaryWrapper: {
flexGrow: 2, flexGrow: 2,

View File

@@ -4,15 +4,13 @@ import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface BackgroundChangerProps { export const BackgroundChanger = () => {
defaultValue: string | undefined;
}
export const BackgroundChanger = ({ defaultValue }: BackgroundChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance'); const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const [backgroundImageUrl, setBackgroundImageUrl] = useState(defaultValue); const [backgroundImageUrl, setBackgroundImageUrl] = useState(
config?.settings.customization.backgroundImageUrl
);
if (!configName) return null; if (!configName) return null;

View File

@@ -4,21 +4,19 @@ import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface FaviconChangerProps { export const FaviconChanger = () => {
defaultValue: string | undefined;
}
export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance'); const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const [faviconUrl, setFaviconUrl] = useState(defaultValue); const [faviconUrl, setFaviconUrl] = useState(
config?.settings.customization.faviconUrl ?? '/imgs/favicon/favicon-squared.png'
);
if (!configName) return null; if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const { value } = ev.currentTarget; const { value } = ev.currentTarget;
const faviconUrl = value.trim().length === 0 ? undefined : value; const faviconUrl = value.trim();
setFaviconUrl(faviconUrl); setFaviconUrl(faviconUrl);
updateConfig(configName, (prev) => ({ updateConfig(configName, (prev) => ({
...prev, ...prev,
@@ -35,6 +33,7 @@ export const FaviconChanger = ({ defaultValue }: FaviconChangerProps) => {
return ( return (
<TextInput <TextInput
label={t('favicon.label')} label={t('favicon.label')}
description={t('favicon.description')}
placeholder="/imgs/favicon/favicon.svg" placeholder="/imgs/favicon/favicon.svg"
value={faviconUrl} value={faviconUrl}
onChange={handleChange} onChange={handleChange}

View File

@@ -4,21 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface LogoImageChangerProps { export const LogoImageChanger = () => {
defaultValue: string | undefined;
}
export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance'); const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const [logoImageSrc, setLogoImageSrc] = useState(defaultValue); const [logoImageSrc, setLogoImageSrc] = useState(config?.settings.customization.logoImageUrl ?? '/imgs/logo/logo.png');
if (!configName) return null; if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const { value } = ev.currentTarget; const { value } = ev.currentTarget;
const logoImageSrc = value.trim().length === 0 ? undefined : value; const logoImageSrc = value.trim();
setLogoImageSrc(logoImageSrc); setLogoImageSrc(logoImageSrc);
updateConfig(configName, (prev) => ({ updateConfig(configName, (prev) => ({
...prev, ...prev,
@@ -35,9 +31,11 @@ export const LogoImageChanger = ({ defaultValue }: LogoImageChangerProps) => {
return ( return (
<TextInput <TextInput
label={t('logo.label')} label={t('logo.label')}
description={t('logo.description')}
placeholder="/imgs/logo/logo.png" placeholder="/imgs/logo/logo.png"
value={logoImageSrc} value={logoImageSrc}
onChange={handleChange} onChange={handleChange}
mb="sm"
/> />
); );
}; };

View File

@@ -4,22 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface MetaTitleChangerProps { export const BrowserTabTitle = () => {
defaultValue: string | undefined;
}
// TODO: change to pageTitle
export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance'); const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const [metaTitle, setMetaTitle] = useState(defaultValue); const [metaTitle, setMetaTitle] = useState(config?.settings.customization.metaTitle ?? '');
if (!configName) return null; if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const { value } = ev.currentTarget; const { value } = ev.currentTarget;
const metaTitle = value.trim().length === 0 ? undefined : value; const metaTitle = value.trim();
setMetaTitle(metaTitle); setMetaTitle(metaTitle);
updateConfig(configName, (prev) => ({ updateConfig(configName, (prev) => ({
...prev, ...prev,
@@ -36,9 +31,11 @@ export const MetaTitleChanger = ({ defaultValue }: MetaTitleChangerProps) => {
return ( return (
<TextInput <TextInput
label={t('metaTitle.label')} label={t('metaTitle.label')}
description={t('metaTitle.description')}
placeholder="homarr - the best dashboard" placeholder="homarr - the best dashboard"
value={metaTitle} value={metaTitle}
onChange={handleChange} onChange={handleChange}
mb="sm"
/> />
); );
}; };

View File

@@ -4,22 +4,17 @@ import { ChangeEventHandler, useState } from 'react';
import { useConfigContext } from '../../../../config/provider'; import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface PageTitleChangerProps { export const DashboardTitleChanger = () => {
defaultValue: string | undefined;
}
// TODO: change to dashboard title
export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => {
const { t } = useTranslation('settings/customization/page-appearance'); const { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const [pageTitle, setPageTitle] = useState(defaultValue); const [pageTitle, setPageTitle] = useState(config?.settings.customization.pageTitle ?? '');
if (!configName) return null; if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (ev) => {
const { value } = ev.currentTarget; const { value } = ev.currentTarget;
const pageTitle = value.trim().length === 0 ? undefined : value; const pageTitle = value.trim();
setPageTitle(pageTitle); setPageTitle(pageTitle);
updateConfig(configName, (prev) => ({ updateConfig(configName, (prev) => ({
...prev, ...prev,
@@ -36,9 +31,11 @@ export const PageTitleChanger = ({ defaultValue }: PageTitleChangerProps) => {
return ( return (
<TextInput <TextInput
label={t('pageTitle.label')} label={t('pageTitle.label')}
description={t('pageTitle.description')}
placeholder="homarr" placeholder="homarr"
value={pageTitle} value={pageTitle}
onChange={handleChange} onChange={handleChange}
mb="sm"
/> />
); );
}; };

View File

@@ -24,7 +24,7 @@ export function ColorSelector({ type, defaultValue }: ColorControlProps) {
const [color, setColor] = useState(defaultValue); const [color, setColor] = useState(defaultValue);
const [popoverOpened, popover] = useDisclosure(false); const [popoverOpened, popover] = useDisclosure(false);
const { setPrimaryColor, setSecondaryColor } = useColorTheme(); const { setPrimaryColor, setSecondaryColor } = useColorTheme();
const { name: configName } = useConfigContext(); const { config, name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const theme = useMantineTheme(); 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 { 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 { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface CustomCssChangerProps { const CodeEditor = dynamic(
defaultValue: string | undefined; () => 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 { t } = useTranslation('settings/customization/page-appearance');
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext(); const { colorScheme, colors } = useMantineTheme();
const [customCss, setCustomCss] = useState(defaultValue); 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; if (!configName) return null;
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (ev) => { useEffect(() => {
const { value } = ev.currentTarget;
const customCss = value.trim().length === 0 ? undefined : value;
setCustomCss(customCss);
updateConfig(configName, (prev) => ({ updateConfig(configName, (prev) => ({
...prev, ...prev,
settings: { settings: {
...prev.settings, ...prev.settings,
customization: { customization: {
...prev.settings.customization, ...prev.settings.customization,
customCss, customCss: debouncedCustomCSS,
}, },
}, },
})); }));
}; }, [debouncedCustomCSS]);
const codeIsDirty = nonDebouncedCustomCSS !== debouncedCustomCSS;
const codeEditorHeight = codeIsDirty ? 250 - 42 : 250;
return ( return (
<Textarea <Stack spacing={4} mt="xl">
minRows={5} <Text>{t('customCSS.label')}</Text>
label={t('customCSS.label')} <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')} placeholder={t('customCSS.placeholder')}
value={customCss} value={nonDebouncedCustomCSS}
onChange={handleChange} 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 { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store'; import { useConfigStore } from '../../../../config/store';
interface OpacitySelectorProps { export function DashboardTilesOpacitySelector() {
defaultValue: number | undefined; const { config, name: configName } = useConfigContext();
} const [opacity, setOpacity] = useState(config?.settings.customization.appOpacity || 100);
export function OpacitySelector({ defaultValue }: OpacitySelectorProps) {
const [opacity, setOpacity] = useState(defaultValue || 100);
const { t } = useTranslation('settings/customization/opacity-selector'); const { t } = useTranslation('settings/customization/opacity-selector');
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig); const updateConfig = useConfigStore((x) => x.updateConfig);
if (!configName) return null; if (!configName) return null;

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { Box, createStyles, Group, Header as MantineHeader, Indicator } from '@mantine/core'; import { Box, createStyles, Group, Header as MantineHeader, Indicator } from '@mantine/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { REPO_URL } from '../../../../data/constants'; import { REPO_URL } from '../../../../data/constants';
import DockerMenuButton from '../../../modules/Docker/DockerModule'; import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { usePackageAttributesStore } from '../../../tools/client/zustands/usePackageAttributesStore'; 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 { WidgetsRemoveModal } from '../components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/CategoryEditModal'; import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/CategoryEditModal';
import { ConfigProvider } from '../config/provider'; import { ConfigProvider } from '../config/provider';
import '../styles/global.scss';
import { ColorTheme } from '../tools/color'; import { ColorTheme } from '../tools/color';
import { queryClient } from '../tools/queryClient'; import { queryClient } from '../tools/queryClient';
import { theme } from '../tools/theme'; import { theme } from '../tools/theme';
@@ -28,6 +27,9 @@ import {
} from '../tools/server/getPackageVersion'; } from '../tools/server/getPackageVersion';
import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore'; import { usePackageAttributesStore } from '../tools/client/zustands/usePackageAttributesStore';
import '../styles/global.scss';
import '@uiw/react-textarea-code-editor/dist.css';
function App( function App(
this: any, this: any,
props: AppProps & { colorScheme: ColorScheme; packageAttributes: ServerSidePackageAttributesType } props: AppProps & { colorScheme: ColorScheme; packageAttributes: ServerSidePackageAttributesType }

View File

@@ -18,7 +18,7 @@
} }
// Styling for grid-stack main area // 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-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-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}) } .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)}) } .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}) } .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 } .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 } .grid-stack.grid-stack-sidebar>.grid-stack-item[gs-x="#{$i}"] { left: 128px * $i }
} }

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/internationalization',
'settings/general/search-engine', 'settings/general/search-engine',
'settings/general/widget-positions', 'settings/general/widget-positions',
'settings/customization/general',
'settings/customization/color-selector', 'settings/customization/color-selector',
'settings/customization/page-appearance', 'settings/customization/page-appearance',
'settings/customization/shade-selector', 'settings/customization/shade-selector',
'settings/customization/app-width', 'settings/customization/app-width',
'settings/customization/opacity-selector', 'settings/customization/opacity-selector',
'settings/customization/gridstack',
'modules/common', 'modules/common',
'modules/date', 'modules/date',
'modules/calendar', 'modules/calendar',

View File

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

View File

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

View File

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

1927
yarn.lock

File diff suppressed because it is too large Load Diff