mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-12 08:25:47 +01:00
Merge branch 'manuel-rw-gridstack' into gridstack-wip-meierschlumpf
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -97,8 +97,8 @@ export const AvailableElementTypes = ({
|
||||
iconUrl: '/imgs/logo/logo.png',
|
||||
},
|
||||
network: {
|
||||
enabledStatusChecker: false,
|
||||
okStatus: [],
|
||||
enabledStatusChecker: true,
|
||||
okStatus: [200],
|
||||
},
|
||||
behaviour: {
|
||||
isOpeningNewTab: true,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user