Merge branch 'manuel-rw-gridstack' into gridstack-wip-meierschlumpf

This commit is contained in:
Meierschlumpf
2023-01-07 17:59:43 +01:00
56 changed files with 962 additions and 785 deletions

View File

@@ -31,6 +31,7 @@ import { CURRENT_VERSION } from '../../../data/constants';
import { useConfigContext } from '../../config/provider';
import { useConfigStore } from '../../config/store';
import { usePrimaryGradient } from '../layout/useGradient';
import Credits from '../Settings/Common/Credits';
interface AboutModalProps {
opened: boolean;
@@ -113,6 +114,7 @@ export const AboutModal = ({ opened, closeModal, newVersionAvailable }: AboutMod
Discord
</Button>
</Group>
<Credits />
</Modal>
);
};

View File

@@ -1,5 +1,7 @@
import { Center, Loader, Select, Tooltip } from '@mantine/core';
import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core';
import { useToggle } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../config/provider';
@@ -7,23 +9,26 @@ import { useConfigContext } from '../../config/provider';
export default function ConfigChanger() {
const { t } = useTranslation('settings/general/config-changer');
const { name: configName } = useConfigContext();
//const loadConfig = useConfigStore((x) => x.loadConfig);
// const loadConfig = useConfigStore((x) => x.loadConfig);
const { data: configs, isLoading, isError } = useConfigsQuery();
const [activeConfig, setActiveConfig] = useState(configName);
const [isRefreshing, toggle] = useToggle();
const onConfigChange = (value: string) => {
// TODO: check what should happen here with @manuel-rw
// Wheter it should check for the current url and then load the new config only on index
// Or it should always load the selected config and open index or ? --> change url to page
setCookie('config-name', value ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
setActiveConfig(value);
/*
loadConfig(e ?? 'default');
setCookie('config-name', e ?? 'default', {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'strict',
});
*/
toggle();
// Use timeout to wait for the cookie to be set
setTimeout(() => {
window.location.reload();
}, 1000);
};
// If configlist is empty, return a loading indicator
@@ -38,12 +43,26 @@ export default function ConfigChanger() {
}
return (
<Select
label={t('configSelect.label')}
value={activeConfig}
onChange={onConfigChange}
data={configs}
/>
<>
<Select
label={t('configSelect.label')}
value={activeConfig}
onChange={onConfigChange}
data={configs}
/>
<Dialog
position={{ top: 0, left: 0 }}
unstyled
opened={isRefreshing}
onClose={() => toggle()}
size="lg"
radius="md"
>
<Notification loading title={t('configSelect.loadingNew')} radius="md" disallowClose>
{t('configSelect.pleaseWait')}
</Notification>
</Dialog>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { Group, Stack, Text, Title, useMantineTheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { showNotification } from '@mantine/notifications';
import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons';
import Consola from 'consola';
import { setCookie } from 'cookies-next';
import { useTranslation } from 'next-i18next';
import { useConfigStore } from '../../config/store';
@@ -36,9 +37,7 @@ export const LoadConfigComponent = () => {
let newConfig: ConfigType = JSON.parse(fileText);
if (!newConfig.schemaVersion) {
// client side logging
// eslint-disable-next-line no-console
console.warn(
Consola.warn(
'a legacy configuration schema was deteced and migrated to the current schema'
);
const oldConfig = JSON.parse(fileText) as Config;

View File

@@ -1,4 +1,4 @@
import { ActionIcon, createStyles } from '@mantine/core';
import { ActionIcon, createStyles, Space } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconChevronLeft, IconChevronRight } from '@tabler/icons';
import { useConfigContext } from '../../../../config/provider';
@@ -35,7 +35,9 @@ export const MobileRibbons = () => {
location="left"
/>
</>
) : null}
) : (
<Space />
)}
{layoutSettings.enabledRightSidebar ? (
<>

View File

@@ -1,4 +1,5 @@
import { Drawer, Title } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { DashboardSidebar } from '../../Wrappers/Sidebar/Sidebar';
interface MobileRibbonSidebarDrawerProps {
@@ -10,16 +11,25 @@ interface MobileRibbonSidebarDrawerProps {
export const MobileRibbonSidebarDrawer = ({
location,
...props
}: MobileRibbonSidebarDrawerProps) => (
<Drawer
position={location}
title={<Title order={4}>{location} sidebar</Title>}
style={{
display: 'flex',
justifyContent: 'center',
}}
{...props}
>
<DashboardSidebar location={location} isGridstackReady />
</Drawer>
);
}: MobileRibbonSidebarDrawerProps) => {
const { t } = useTranslation('layout/mobile/drawer');
return (
<Drawer
padding={10}
position={location}
title={<Title order={4}>{t('title', { position: location })}</Title>}
style={{
display: 'flex',
justifyContent: 'center',
}}
styles={{
title: {
width: '100%',
},
}}
{...props}
>
<DashboardSidebar location={location} isGridstackReady />
</Drawer>
);
};

View File

@@ -36,7 +36,7 @@ export const IconSelector = ({ onChange, allowAppNamePropagation, form }: IconSe
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
url: 'https://api.github.com/repos/walkxcode/Dashboard-Icons/contents/png',
converter: (item) => ({
url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${item.name}`,
url: `https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/${item.name}`,
fileName: item.name,
}),
});

View File

@@ -9,15 +9,21 @@ import {
Stack,
ThemeIcon,
Title,
Text,
Badge,
Tooltip,
} from '@mantine/core';
import { TablerIcon } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { AppIntegrationPropertyAccessabilityType } from '../../../../../../../../types/app';
interface GenericSecretInputProps {
label: string;
value: string;
setIcon: TablerIcon;
secretIsPresent: boolean;
type: AppIntegrationPropertyAccessabilityType;
onClickUpdateButton: (value: string | undefined) => void;
}
@@ -25,6 +31,8 @@ export const GenericSecretInput = ({
label,
value,
setIcon,
secretIsPresent,
type,
onClickUpdateButton,
...props
}: GenericSecretInputProps) => {
@@ -36,17 +44,61 @@ export const GenericSecretInput = ({
const { t } = useTranslation(['layout/modals/add-app', 'common']);
return (
<Card withBorder>
<Card p="xs" withBorder>
<Grid>
<Grid.Col className={classes.alignSelfCenter} xs={12} md={6}>
<Group spacing="sm">
<ThemeIcon color="green" variant="light">
<ThemeIcon color={secretIsPresent ? 'green' : 'red'} variant="light" size="lg">
<Icon size={18} />
</ThemeIcon>
<Stack spacing={0}>
<Title className={classes.subtitle} order={6}>
{t(label)}
</Title>
<Group spacing="xs">
<Title className={classes.subtitle} order={6}>
{t(label)}
</Title>
<Group spacing="xs">
{secretIsPresent ? (
<Badge className={classes.textTransformUnset} color="green" variant="dot">
{t('integration.type.defined')}
</Badge>
) : (
<Badge className={classes.textTransformUnset} color="red" variant="dot">
{t('integration.type.undefined')}
</Badge>
)}
{type === 'private' ? (
<Tooltip
label={t('integration.type.explanationPrivate')}
width={200}
multiline
withinPortal
withArrow
>
<Badge className={classes.textTransformUnset} color="orange" variant="dot">
{t('integration.type.private')}
</Badge>
</Tooltip>
) : (
<Tooltip
label={t('integration.type.explanationPublic')}
width={200}
multiline
withinPortal
withArrow
>
<Badge className={classes.textTransformUnset} color="red" variant="dot">
{t('integration.type.public')}
</Badge>
</Tooltip>
)}
</Group>
</Group>
<Text size="xs" color="dimmed">
{type === 'private'
? 'Private: Once saved, you cannot read out this value again'
: 'Public: Can be read out repeatedly'}
</Text>
</Stack>
</Group>
</Grid.Col>
@@ -80,4 +132,7 @@ const useStyles = createStyles(() => ({
alignSelfCenter: {
alignSelf: 'center',
},
textTransformUnset: {
textTransform: 'inherit',
},
}));

View File

@@ -36,7 +36,7 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
label: 'Transmission',
},
{
value: 'qbittorrent',
value: 'qBittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/qbittorrent.png',
label: 'qBittorrent',
},
@@ -100,16 +100,20 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
placeholder={t('integration.type.placeholder')}
itemComponent={SelectItemComponent}
data={data}
maxDropdownHeight={150}
maxDropdownHeight={250}
dropdownPosition="bottom"
clearable
variant="default"
searchable
filter={(value, item) =>
item.label?.toLowerCase().includes(value.toLowerCase().trim()) ||
item.description?.toLowerCase().includes(value.toLowerCase().trim())
}
icon={
form.values.integration?.type && (
<img
src={data.find((x) => x.value === form.values.integration?.type)?.image}
alt="test"
alt="integration"
width={20}
height={20}
/>
@@ -119,6 +123,7 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
form.setFieldValue('integration.properties', getNewProperties(value));
inputProps.onChange(value);
}}
withinPortal
{...inputProps}
/>
);
@@ -126,17 +131,23 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
image: string;
description: string;
label: string;
}
const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
({ image, label, ...others }: ItemProps, ref) => (
({ image, label, description, ...others }: ItemProps, ref) => (
<div ref={ref} {...others}>
<Group noWrap>
<img src={image} alt="integration icon" width={20} height={20} />
<div>
<Text size="sm">{label}</Text>
{description && (
<Text size="xs" color="dimmed">
{description}
</Text>
)}
</div>
</Group>
</div>

View File

@@ -45,6 +45,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
const formValue = form.values.integration?.properties[indexInFormValue];
const isPresent = formValue?.isDefined;
const accessabilityType = formValue?.type;
if (!definition) {
return (
@@ -57,6 +58,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
secretIsPresent={isPresent}
setIcon={IconKey}
value={formValue.value}
type={accessabilityType}
{...form.getInputProps(`integration.properties.${index}.value`)}
/>
);
@@ -72,6 +74,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
value=""
secretIsPresent={isPresent}
setIcon={definition.icon}
type={accessabilityType}
{...form.getInputProps(`integration.properties.${index}.value`)}
/>
);

View File

@@ -97,8 +97,8 @@ export const AvailableElementTypes = ({
iconUrl: '/imgs/logo/logo.png',
},
network: {
enabledStatusChecker: false,
okStatus: [],
enabledStatusChecker: true,
okStatus: [200],
},
behaviour: {
isOpeningNewTab: true,

View File

@@ -1,5 +1,6 @@
import { useModals } from '@mantine/modals';
import { TablerIcon } from '@tabler/icons';
import { showNotification } from '@mantine/notifications';
import { IconChecks, TablerIcon } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../../../config/provider';
import { useConfigStore } from '../../../../../../config/store';
@@ -83,8 +84,13 @@ export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElement
true,
!isEditMode
);
closeModal('selectElement');
showNotification({
title: t('descriptor.name'),
message: t('descriptor.description'),
icon: <IconChecks stroke={1.5} />,
color: 'teal',
});
};
return (

View File

@@ -1,4 +1,15 @@
import { Alert, Button, Group, MultiSelect, Stack, Switch, TextInput, Text } from '@mantine/core';
import {
Alert,
Button,
Group,
MultiSelect,
Stack,
Switch,
TextInput,
Text,
NumberInput,
Slider,
} from '@mantine/core';
import { ContextModalProps } from '@mantine/modals';
import { IconAlertTriangle } from '@tabler/icons';
import { Trans, useTranslation } from 'next-i18next';
@@ -8,10 +19,12 @@ import type { IWidgetOptionValue } from '../../../../widgets/widgets';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { IWidget } from '../../../../widgets/widgets';
import { useColorTheme } from '../../../../tools/color';
export type WidgetEditModalInnerProps = {
widgetId: string;
options: IWidget<string, any>['properties'];
widgetOptions: IWidget<string, any>['properties'];
};
type IntegrationOptionsValueType = IWidget<string, any>['properties'][string];
@@ -23,7 +36,11 @@ export const WidgetsEditModal = ({
}: ContextModalProps<WidgetEditModalInnerProps>) => {
const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']);
const [moduleProperties, setModuleProperties] = useState(innerProps.options);
const items = Object.entries(moduleProperties ?? {}) as [string, IntegrationOptionsValueType][];
// const items = Object.entries(moduleProperties ?? {}) as [string, IntegrationOptionsValueType][];
const items = Object.entries(innerProps.widgetOptions ?? {}) as [
string,
IntegrationOptionsValueType
][];
// Find the Key in the "Widgets" Object that matches the widgetId
const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets];
@@ -67,8 +84,9 @@ export const WidgetsEditModal = ({
return (
<Stack>
{items.map(([key, value], index) => {
{items.map(([key, defaultValue], index) => {
const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
const value = moduleProperties[key] ?? defaultValue;
if (!option) {
return (
@@ -83,39 +101,15 @@ export const WidgetsEditModal = ({
</Alert>
);
}
switch (option.type) {
case 'switch':
return (
<Switch
key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)}
checked={value as boolean}
onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
/>
);
case 'text':
return (
<TextInput
key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)}
value={value as string}
onChange={(ev) => handleChange(key, ev.currentTarget.value)}
/>
);
case 'multi-select':
return (
<MultiSelect
key={`${option.type}-${index}`}
data={getMutliselectData(key)}
label={t(`descriptor.settings.${key}.label`)}
value={value as string[]}
onChange={(v) => handleChange(key, v)}
/>
);
default:
return null;
}
return WidgetOptionTypeSwitch(
option,
index,
t,
key,
value,
handleChange,
getMutliselectData
);
})}
<Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light">
@@ -126,3 +120,77 @@ export const WidgetsEditModal = ({
</Stack>
);
};
// Widget switch
// Widget options are computed based on their type.
// here you can define new types for options (along with editing the widgets.d.ts file)
function WidgetOptionTypeSwitch(
option: IWidgetOptionValue,
index: number,
t: any,
key: string,
value: string | number | boolean | string[],
handleChange: (key: string, value: IntegrationOptionsValueType) => void,
getMutliselectData: (option: string) => any
) {
const { primaryColor, secondaryColor } = useColorTheme();
switch (option.type) {
case 'switch':
return (
<Switch
key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)}
checked={value as boolean}
onChange={(ev) => handleChange(key, ev.currentTarget.checked)}
/>
);
case 'text':
return (
<TextInput
color={primaryColor}
key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)}
value={value as string}
onChange={(ev) => handleChange(key, ev.currentTarget.value)}
/>
);
case 'multi-select':
return (
<MultiSelect
color={primaryColor}
key={`${option.type}-${index}`}
data={getMutliselectData(key)}
label={t(`descriptor.settings.${key}.label`)}
value={value as string[]}
onChange={(v) => handleChange(key, v)}
/>
);
case 'number':
return (
<NumberInput
color={primaryColor}
key={`${option.type}-${index}`}
label={t(`descriptor.settings.${key}.label`)}
value={value as number}
onChange={(v) => handleChange(key, v!)}
/>
);
case 'slider':
return (
<Stack spacing="xs">
<Text>{t(`descriptor.settings.${key}.label`)}</Text>
<Slider
color={primaryColor}
key={`${option.type}-${index}`}
value={value as number}
min={option.min}
max={option.max}
step={option.step}
onChange={(v) => handleChange(key, v)}
/>
</Stack>
);
default:
return null;
}
}

View File

@@ -6,6 +6,7 @@ import { useWrapperColumnCount } from '../../Wrappers/gridstack/store';
import { GenericTileMenu } from '../GenericTileMenu';
import { WidgetEditModalInnerProps } from './WidgetsEditModal';
import { WidgetsRemoveModalInnerProps } from './WidgetsRemoveModal';
import WidgetsDefinitions from '../../../../widgets';
export type WidgetChangePositionModalInnerProps = {
widgetId: string;
@@ -23,6 +24,14 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
const wrapperColumnCount = useWrapperColumnCount();
if (!widget || !wrapperColumnCount) return null;
// Match widget.id with WidgetsDefinitions
// First get the keys
const keys = Object.keys(WidgetsDefinitions);
// Then find the key that matches the widget.id
const widgetDefinition = keys.find((key) => key === widget.id);
// Then get the widget definition
const widgetDefinitionObject =
WidgetsDefinitions[widgetDefinition as keyof typeof WidgetsDefinitions];
const handleDeleteClick = () => {
openContextModalGeneric<WidgetsRemoveModalInnerProps>({
@@ -54,7 +63,10 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
innerProps: {
widgetId: integration,
options: widget.properties,
// Cast as the right type for the correct widget
widgetOptions: widgetDefinitionObject.options as any,
},
zIndex: 5,
});
};

View File

@@ -1,5 +1,6 @@
import { Card } from '@mantine/core';
import { RefObject } from 'react';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useGridstack } from '../gridstack/use-gridstack';
import { WrapperContent } from '../WrapperContent';
@@ -30,18 +31,23 @@ const SidebarInner = ({ location }: DashboardSidebarInnerProps) => {
const { refs, apps, widgets } = useGridstack('sidebar', location);
const minRow = useMinRowForFullHeight(refs.wrapper);
const {
cx,
classes: { card: cardClass },
} = useCardStyles(false);
return (
<div
className="grid-stack grid-stack-sidebar"
style={{ transitionDuration: '0s', height: '100%' }}
data-sidebar={location}
// eslint-disable-next-line react/no-unknown-property
gs-min-row={minRow}
ref={refs.wrapper}
>
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div>
<Card withBorder mih="100%" p={0} radius="lg" className={cardClass} ref={refs.wrapper}>
<div
className="grid-stack grid-stack-sidebar"
style={{ transitionDuration: '0s', height: '100%' }}
data-sidebar={location}
// eslint-disable-next-line react/no-unknown-property
gs-min-row={minRow}
>
<WrapperContent apps={apps} refs={refs} widgets={widgets} />
</div>
</Card>
);
};

View File

@@ -1,4 +1,5 @@
import { Space, Stack, Text } from '@mantine/core';
import { ScrollArea, Space, Stack, Text } from '@mantine/core';
import { useViewportSize } from '@mantine/hooks';
import { useConfigContext } from '../../../config/provider';
import ConfigChanger from '../../Config/ConfigChanger';
import ConfigActions from './Config/ConfigActions';
@@ -7,6 +8,7 @@ import { SearchEngineSelector } from './SearchEngine/SearchEngineSelector';
export default function CommonSettings() {
const { config } = useConfigContext();
const { height, width } = useViewportSize();
if (!config) {
return (
@@ -15,14 +17,15 @@ export default function CommonSettings() {
</Text>
);
}
return (
<Stack mb="md" mr="sm">
<SearchEngineSelector searchEngine={config.settings.common.searchEngine} />
<Space />
<LanguageSelect />
<ConfigChanger />
<ConfigActions />
</Stack>
<ScrollArea style={{ height: height - 100 }} offsetScrollbars>
<Stack>
<SearchEngineSelector searchEngine={config.settings.common.searchEngine} />
<Space />
<LanguageSelect />
<ConfigChanger />
<ConfigActions />
</Stack>
</ScrollArea>
);
}

View File

@@ -35,7 +35,7 @@ export default function ConfigActions() {
closeModal={createCopyModal.close}
initialConfigName={config.configProperties.name}
/>
<Flex gap="xs" justify="stretch">
<Flex gap="xs" mt="xs" justify="stretch">
<ActionIcon className={classes.actionIcon} onClick={handleDownload} variant="default">
<IconDownload size={20} />
<Text size="sm">{t('buttons.download')}</Text>

View File

@@ -1,48 +1,27 @@
import { Group, ActionIcon, Anchor, Text } from '@mantine/core';
import { IconBrandDiscord, IconBrandGithub } from '@tabler/icons';
import { Group, Anchor, Text } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { CURRENT_VERSION } from '../../../../data/constants';
export default function Credits() {
const { t } = useTranslation('settings/common');
return (
<Group position="center" mt="xs">
<Group spacing={0}>
<ActionIcon<'a'> component="a" href="https://github.com/ajnart/homarr" size="lg">
<IconBrandGithub size={18} />
</ActionIcon>
<Text
style={{
position: 'relative',
fontSize: '0.90rem',
color: 'gray',
}}
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: 'gray',
}}
>
{t('credits.madeWithLove')}
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
{CURRENT_VERSION}
</Text>
</Group>
<Group spacing={1}>
<Text
style={{
fontSize: '0.90rem',
textAlign: 'center',
color: 'gray',
}}
>
{t('credits.madeWithLove')}
<Anchor
href="https://github.com/ajnart"
style={{ color: 'inherit', fontStyle: 'inherit', fontSize: 'inherit' }}
>
ajnart
</Anchor>
</Text>
<ActionIcon<'a'> component="a" href="https://discord.gg/aCsmEV5RgA" size="lg">
<IconBrandDiscord size={18} />
</ActionIcon>
</Group>
ajnart
</Anchor>{' '}
and you !
</Text>
</Group>
);
}

View File

@@ -50,7 +50,7 @@ export const SearchEngineSelector = ({ searchEngine }: Props) => {
/>
<Paper p="md" py="sm" mb="md" withBorder>
<Title order={6} mb={0}>
Search engine configuration
{t('configurationName')}
</Title>
<SearchNewTabSwitch defaultValue={searchEngine.properties.openInNewTab} />

View File

@@ -1,4 +1,6 @@
import { Button, ScrollArea, Stack } 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';
@@ -14,20 +16,14 @@ import { ShadeSelector } from './Theme/ShadeSelector';
export default function CustomizationSettings() {
const { config, name: configName } = useConfigContext();
const { t } = useTranslation('common');
const { height, width } = useViewportSize();
const { updateConfig } = useConfigStore();
const saveConfiguration = () => {
if (!configName || !config) {
return;
}
updateConfig(configName, (_) => config, false, true);
};
return (
<Stack mb="md" mr="sm" mt="xs">
<ScrollArea style={{ height: '76vh' }} offsetScrollbars>
<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} />
@@ -45,11 +41,7 @@ export default function CustomizationSettings() {
/>
<ShadeSelector defaultValue={config?.settings.customization.colors.shade} />
<OpacitySelector defaultValue={config?.settings.customization.appOpacity} />
</ScrollArea>
<Button onClick={saveConfiguration} variant="light">
Save Customizations
</Button>
</Stack>
</Stack>
</ScrollArea>
);
}

View File

@@ -32,7 +32,7 @@ export function OpacitySelector({ defaultValue }: OpacitySelectorProps) {
};
return (
<Stack spacing="xs">
<Stack spacing="xs" mb="md">
<Text>{t('label')}</Text>
<Slider
defaultValue={opacity}

View File

@@ -1,8 +1,9 @@
import { Drawer, ScrollArea, Tabs, Title } from '@mantine/core';
import { Drawer, Tabs, Title } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../config/provider';
import { useConfigStore } from '../../config/store';
import CommonSettings from './Common/CommonSettings';
import Credits from './Common/Credits';
import CustomizationSettings from './Customization/CustomizationSettings';
function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string }) {
@@ -15,9 +16,7 @@ function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: string })
<Tabs.Tab value="customization">{t('tabs.customizations')}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel data-autofocus value="common">
<ScrollArea style={{ height: '78vh' }} offsetScrollbars>
<CommonSettings />
</ScrollArea>
<CommonSettings />
</Tabs.Panel>
<Tabs.Panel value="customization">
<CustomizationSettings />
@@ -37,6 +36,8 @@ export function SettingsDrawer({
newVersionAvailable,
}: SettingsDrawerProps & { newVersionAvailable: string }) {
const { t } = useTranslation('settings/common');
const { config, name: configName } = useConfigContext();
const { updateConfig } = useConfigStore();
return (
<Drawer
@@ -45,10 +46,16 @@ export function SettingsDrawer({
position="right"
title={<Title order={5}>{t('title')}</Title>}
opened={opened}
onClose={closeDrawer}
onClose={() => {
closeDrawer();
if (!configName || !config) {
return;
}
updateConfig(configName, (_) => config, false, true);
}}
>
<SettingsMenu newVersionAvailable={newVersionAvailable} />
<Credits />
</Drawer>
);
}

View File

@@ -15,9 +15,8 @@ export const AddElementAction = ({ type }: AddElementActionProps) => {
return (
<Tooltip label={t('actionIcon.tooltip')} withinPortal withArrow>
<Button
variant="default"
radius="md"
color="blue"
variant="default"
style={{ height: 43 }}
onClick={() =>
openContextModal({

View File

@@ -1,231 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Button, Group } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import {
IconCheck,
IconPlayerPlay,
IconPlayerStop,
IconPlus,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from '@tabler/icons';
import axios from 'axios';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { TFunction } from 'react-i18next';
import { v4 as uuidv4 } from 'uuid';
import { openContextModalGeneric } from '../../../../../tools/mantineModalManagerExtensions';
import { AppType } from '../../../../../types/app';
let t: TFunction<'modules/docker', undefined>;
function sendDockerCommand(
action: string,
containerId: string,
containerName: string,
reload: () => void
) {
showNotification({
id: containerId,
loading: true,
title: `${t(`actions.${action}.start`)} ${containerName}`,
message: undefined,
autoClose: false,
disallowClose: true,
});
axios
.get(`/api/docker/container/${containerId}?action=${action}`)
.then((res) => {
updateNotification({
id: containerId,
title: containerName,
message: `${t(`actions.${action}.end`)} ${containerName}`,
icon: <IconCheck />,
autoClose: 2000,
});
})
.catch((err) => {
updateNotification({
id: containerId,
color: 'red',
title: t('errors.unknownError.title'),
message: err.response.data.reason,
autoClose: 2000,
});
})
.finally(() => {
reload();
});
}
export interface ContainerActionBarProps {
selected: Dockerode.ContainerInfo[];
reload: () => void;
}
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
t = useTranslation('modules/docker').t;
const [isLoading, setisLoading] = useState(false);
return (
<Group spacing="xs">
<Button
leftIcon={<IconRefresh />}
onClick={() => {
setisLoading(true);
setTimeout(() => {
reload();
setisLoading(false);
}, 750);
}}
variant="light"
color="violet"
loading={isLoading}
radius="md"
>
{t('actionBar.refreshData.title')}
</Button>
<Button
leftIcon={<IconRotateClockwise />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('restart', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="orange"
radius="md"
disabled={selected.length === 0}
>
{t('actionBar.restart.title')}
</Button>
<Button
leftIcon={<IconPlayerStop />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('stop', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="red"
radius="md"
disabled={selected.length === 0}
>
{t('actionBar.stop.title')}
</Button>
<Button
leftIcon={<IconPlayerPlay />}
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('start', container.Id, container.Names[0].substring(1), reload)
)
)
}
variant="light"
color="green"
radius="md"
disabled={selected.length === 0}
>
{t('actionBar.start.title')}
</Button>
<Button
leftIcon={<IconTrash />}
color="red"
variant="light"
radius="md"
onClick={() =>
Promise.all(
selected.map((container) =>
sendDockerCommand('remove', container.Id, container.Names[0].substring(1), reload)
)
)
}
disabled={selected.length === 0}
>
{t('actionBar.remove.title')}
</Button>
<Button
leftIcon={<IconPlus />}
color="indigo"
variant="light"
radius="md"
disabled={selected.length === 0 || selected.length > 1}
onClick={() => {
const containerUrl = `http://localhost:${selected[0].Ports[0].PublicPort}`;
openContextModalGeneric<{ service: AppType }>({
modal: 'editService',
innerProps: {
service: {
id: uuidv4(),
name: selected[0].Names[0],
url: containerUrl,
appearance: {
iconUrl: '/imgs/logo/logo.png', // TODO: find icon automatically
},
network: {
enabledStatusChecker: false,
okStatus: [],
},
behaviour: {
isOpeningNewTab: true,
externalUrl: '',
},
area: {
type: 'sidebar', // TODO: Set the wrapper automatically
properties: {
location: 'right',
},
},
shape: {
lg: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
},
md: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
},
sm: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
},
},
integration: {
type: null,
properties: [],
},
},
},
});
}}
>
{t('actionBar.addToHomarr.title')}
</Button>
</Group>
);
}

View File

@@ -1,53 +0,0 @@
import { Badge, BadgeVariant, MantineSize } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import Dockerode from 'dockerode';
export interface ContainerStateProps {
state: Dockerode.ContainerInfo['State'];
}
export default function ContainerState(props: ContainerStateProps) {
const { state } = props;
const { t } = useTranslation('modules/docker');
const options: {
size: MantineSize;
radius: MantineSize;
variant: BadgeVariant;
} = {
size: 'md',
radius: 'md',
variant: 'outline',
};
switch (state) {
case 'running': {
return (
<Badge color="green" {...options}>
{t('table.states.running')}
</Badge>
);
}
case 'created': {
return (
<Badge color="cyan" {...options}>
{t('table.states.created')}
</Badge>
);
}
case 'exited': {
return (
<Badge color="red" {...options}>
{t('table.states.stopped')}
</Badge>
);
}
default: {
return (
<Badge color="purple" {...options}>
{t('table.states.unknown')}
</Badge>
);
}
}
}

View File

@@ -1,81 +0,0 @@
import { ActionIcon, Drawer, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconBrandDocker, IconX } from '@tabler/icons';
import axios from 'axios';
import Docker from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfigContext } from '../../../../../config/provider';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
export default function DockerMenuButton(props: any) {
const [opened, setOpened] = useState(false);
const [containers, setContainers] = useState<Docker.ContainerInfo[]>([]);
const [selection, setSelection] = useState<Docker.ContainerInfo[]>([]);
const { config } = useConfigContext();
const dockerEnabled = config?.settings.customization.layout.enabledDocker || false;
const { t } = useTranslation('modules/docker');
useEffect(() => {
reload();
}, [config?.settings]);
function reload() {
if (!dockerEnabled) {
return;
}
setTimeout(() => {
axios
.get('/api/docker/containers')
.then((res) => {
setContainers(res.data);
setSelection([]);
})
.catch(() => {
// Remove containers from the list
setContainers([]);
// Send an Error notification
showNotification({
autoClose: 1500,
title: <Text>{t('errors.integrationFailed.title')}</Text>,
color: 'red',
icon: <IconX />,
message: t('errors.integrationFailed.message'),
});
});
}, 300);
}
if (!dockerEnabled) {
return null;
}
return (
<>
<Drawer
opened={opened}
onClose={() => setOpened(false)}
padding="xl"
size="full"
title={<ContainerActionBar selected={selection} reload={reload} />}
>
<DockerTable containers={containers} selection={selection} setSelection={setSelection} />
</Drawer>
<Tooltip label={t('actionIcon.tooltip')}>
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
onClick={() => setOpened(true)}
>
<IconBrandDocker />
</ActionIcon>
</Tooltip>
</>
);
}

View File

@@ -1,145 +0,0 @@
import {
Table,
Checkbox,
Group,
Badge,
createStyles,
ScrollArea,
TextInput,
useMantineTheme,
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconSearch } from '@tabler/icons';
import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import ContainerState from './ContainerState';
const useStyles = createStyles((theme) => ({
rowSelected: {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.rgba(theme.colors[theme.primaryColor][7], 0.2)
: theme.colors[theme.primaryColor][0],
},
}));
export default function DockerTable({
containers,
selection,
setSelection,
}: {
setSelection: any;
containers: Dockerode.ContainerInfo[];
selection: Dockerode.ContainerInfo[];
}) {
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const [usedContainers, setContainers] = useState<Dockerode.ContainerInfo[]>(containers);
const { classes, cx } = useStyles();
const [search, setSearch] = useState('');
const { ref, width, height } = useElementSize();
const { t } = useTranslation('modules/docker');
useEffect(() => {
setContainers(containers);
}, [containers]);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
setSearch(value);
setContainers(filterContainers(containers, value));
};
function filterContainers(data: Dockerode.ContainerInfo[], search: string) {
const query = search.toLowerCase().trim();
return data.filter((item) =>
item.Names.some((name) => name.toLowerCase().includes(query) || item.Image.includes(query))
);
}
const toggleRow = (container: Dockerode.ContainerInfo) =>
setSelection((current: Dockerode.ContainerInfo[]) =>
current.includes(container) ? current.filter((c) => c !== container) : [...current, container]
);
const toggleAll = () =>
setSelection((current: any) =>
current.length === usedContainers.length ? [] : usedContainers.map((c) => c)
);
const rows = usedContainers.map((element) => {
const selected = selection.includes(element);
return (
<tr key={element.Id} className={cx({ [classes.rowSelected]: selected })}>
<td>
<Checkbox
checked={selection.includes(element)}
onChange={() => toggleRow(element)}
transitionDuration={0}
/>
</td>
<td>{element.Names[0].replace('/', '')}</td>
{width > MIN_WIDTH_MOBILE && <td>{element.Image}</td>}
{width > MIN_WIDTH_MOBILE && (
<td>
<Group>
{element.Ports.sort((a, b) => a.PrivatePort - b.PrivatePort)
// Remove duplicates with filter function
.filter(
(port, index, self) =>
index === self.findIndex((t) => t.PrivatePort === port.PrivatePort)
)
.slice(-3)
.map((port) => (
<Badge key={port.PrivatePort} variant="outline">
{port.PrivatePort}:{port.PublicPort}
</Badge>
))}
{element.Ports.length > 3 && (
<Badge variant="filled">
{t('table.body.portCollapse', { ports: element.Ports.length - 3 })}
</Badge>
)}
</Group>
</td>
)}
<td>
<ContainerState state={element.State} />
</td>
</tr>
);
});
return (
<ScrollArea style={{ height: '80vh' }}>
<TextInput
placeholder={t('search.placeholder')}
mt="md"
icon={<IconSearch size={14} />}
value={search}
onChange={handleSearchChange}
disabled={usedContainers.length === 0}
/>
<Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm">
<thead>
<tr>
<th style={{ width: 40 }}>
<Checkbox
onChange={toggleAll}
checked={selection.length === usedContainers.length && selection.length > 0}
indeterminate={selection.length > 0 && selection.length !== usedContainers.length}
transitionDuration={0}
disabled={usedContainers.length === 0}
/>
</th>
<th>{t('table.header.name')}</th>
{width > MIN_WIDTH_MOBILE ? <th>{t('table.header.image')}</th> : null}
{width > MIN_WIDTH_MOBILE ? <th>{t('table.header.ports')}</th> : null}
<th>{t('table.header.state')}</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -1,10 +1,11 @@
import axios from 'axios';
import Consola from 'consola';
import { ActionIcon, Button, Group, Popover, Text } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff, IconX } from '@tabler/icons';
import { ActionIcon, Button, Group, Title, Tooltip } from '@mantine/core';
import { IconEditCircle, IconEditCircleOff } from '@tabler/icons';
import { getCookie } from 'cookies-next';
import { Trans, useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { hideNotification, showNotification } from '@mantine/notifications';
import { useConfigContext } from '../../../../../config/provider';
import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan';
@@ -13,7 +14,6 @@ import { AddElementAction } from '../AddElementAction/AddElementAction';
export const ToggleEditModeAction = () => {
const { enabled, toggleEditMode } = useEditModeStore();
const [popoverManuallyHidden, setPopoverManuallyHidden] = useState<boolean>();
const { t } = useTranslation('layout/header/actions/toggle-edit-mode');
@@ -29,21 +29,44 @@ export const ToggleEditModeAction = () => {
const toggleButtonClicked = () => {
toggleEditMode();
if (!enabled) {
showNotification({
styles: (theme) => ({
root: {
backgroundColor: theme.colors.orange[7],
borderColor: theme.colors.orange[7],
setPopoverManuallyHidden(false);
'&::before': { backgroundColor: theme.white },
},
title: { color: theme.white },
description: { color: theme.white },
closeButton: {
color: theme.white,
'&:hover': { backgroundColor: theme.colors.orange[7] },
},
}),
radius: 'md',
id: 'toggle-edit-mode',
autoClose: false,
title: <Title order={4}>{t('popover.title')}</Title>,
message: <Trans i18nKey="layout/header/actions/toggle-edit-mode:popover.text" />,
});
} else {
hideNotification('toggle-edit-mode');
}
};
const ToggleButtonDesktop = () => (
<Button
onClick={() => toggleButtonClicked()}
leftIcon={enabled ? <IconEditCircleOff /> : <IconEditCircle />}
variant="default"
radius="md"
color="blue"
style={{ height: 43 }}
>
<Text>{enabled ? t('button.enabled') : t('button.disabled')}</Text>
</Button>
<Tooltip label={enabled ? t('button.enabled') : t('button.disabled')}>
<Button
onClick={() => toggleButtonClicked()}
radius="md"
variant="default"
style={{ height: 43 }}
>
{enabled ? <IconEditCircleOff /> : <IconEditCircle />}
</Button>
</Tooltip>
);
const ToggleActionIconMobile = () => (
@@ -59,45 +82,24 @@ export const ToggleEditModeAction = () => {
);
return (
<Popover
opened={enabled && !smallerThanSm && !popoverManuallyHidden}
width="target"
transition="scale"
zIndex={199}
>
<Popover.Target>
{smallerThanSm ? (
enabled ? (
<Group style={{ flexWrap: 'nowrap' }}>
<AddElementAction type="action-icon" />
<ToggleActionIconMobile />
</Group>
) : (
<>
{smallerThanSm ? (
enabled ? (
<Group style={{ flexWrap: 'nowrap' }}>
<AddElementAction type="action-icon" />
<ToggleActionIconMobile />
)
) : enabled ? (
<Button.Group>
<ToggleButtonDesktop />
{enabled && <AddElementAction type="button" />}
</Button.Group>
</Group>
) : (
<ToggleActionIconMobile />
)
) : enabled ? (
<Button.Group>
<ToggleButtonDesktop />
)}
</Popover.Target>
<Popover.Dropdown p={4} px={6} mt={-5}>
<div style={{ position: 'absolute', top: 2, right: 2 }}>
<ActionIcon onClick={() => setPopoverManuallyHidden(true)}>
<IconX size={18} />
</ActionIcon>
</div>
<Text align="center" size="sm">
<Text weight="bold">{t('popover.title')}</Text>
<Text>
<Trans i18nKey="layout/header/actions/toggle-edit-mode:popover.text" />
</Text>
</Text>
</Popover.Dropdown>
</Popover>
{enabled && <AddElementAction type="button" />}
</Button.Group>
) : (
<ToggleButtonDesktop />
)}
</>
);
};

View File

@@ -4,7 +4,7 @@ import { CURRENT_VERSION, REPO_URL } from '../../../../data/constants';
import { useConfigContext } from '../../../config/provider';
import { Logo } from '../Logo';
import { useCardStyles } from '../useCardStyles';
import DockerMenuButton from './Actions/Docker/DockerModule';
import DockerMenuButton from '../../../modules/Docker/DockerModule';
import { ToggleEditModeAction } from './Actions/ToggleEditMode/ToggleEditMode';
import { Search } from './Search';
import { SettingsMenu } from './SettingsMenu';

View File

@@ -55,9 +55,6 @@ export function Search() {
const [searchQuery, setSearchQuery] = useState('');
const [debounced, cancel] = useDebouncedValue(searchQuery, 250);
// TODO: ask manuel-rw about overseerr
// Answer: We can simply check if there is a app of the type overseer and display results if there is one.
// Overseerr is not use anywhere else, so it makes no sense to add a standalone toggle for displaying results
const isOverseerrEnabled = config?.apps.some(
(x) => x.integration.type === 'overseerr' || x.integration.type === 'jellyseerr'
);

View File

@@ -1,4 +1,4 @@
import { ActionIcon, Badge, Menu } from '@mantine/core';
import { Badge, Button, Menu } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconInfoCircle, IconMenu2, IconSettings } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
@@ -15,9 +15,9 @@ export function SettingsMenu({ newVersionAvailable }: { newVersionAvailable: str
<>
<Menu width={250}>
<Menu.Target>
<ActionIcon variant="default" radius="md" size="xl" color="blue">
<Button variant="default" radius="md" style={{ height: 43 }}>
<IconMenu2 />
</ActionIcon>
</Button>
</Menu.Target>
<Menu.Dropdown>
<ColorSchemeSwitch />

View File

@@ -8,7 +8,6 @@ interface smallAppItem {
export default function SmallAppItem(props: any) {
const { app }: { app: smallAppItem } = props;
// TODO : Use Next/link
return (
<Group>
{app.icon && <Avatar src={app.icon} />}