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,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} />}

View File

@@ -6,6 +6,7 @@ const POLLING_INTERVAL = 2000;
interface TorrentsDataRequestParams {
appId: string;
refreshInterval: number;
}
export const useGetTorrentData = (params: TorrentsDataRequestParams) =>
@@ -15,7 +16,7 @@ export const useGetTorrentData = (params: TorrentsDataRequestParams) =>
refetchOnWindowFocus: true,
refetchInterval(_: any, query: Query) {
if (query.state.fetchFailureCount < 3) {
return 5000;
return params.refreshInterval;
}
return false;
},

View File

@@ -16,8 +16,11 @@ 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';
import { useConfigContext } from '../../config/provider';
import { tryMatchService } from '../../tools/addToHomarr';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { AppType } from '../../types/app';
import { appTileDefinition } from '../../components/Dashboard/Tiles/Apps/AppTile';
let t: TFunction<'modules/docker', undefined>;
@@ -68,6 +71,8 @@ export interface ContainerActionBarProps {
export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) {
t = useTranslation('modules/docker').t;
const [isLoading, setisLoading] = useState(false);
const { name: configName, config } = useConfigContext();
const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0];
return (
<Group spacing="xs">
@@ -158,61 +163,40 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md"
disabled={selected.length === 0 || selected.length > 1}
onClick={() => {
const app = tryMatchService(selected.at(0)!);
const containerUrl = `http://localhost:${selected[0].Ports[0].PublicPort}`;
openContextModalGeneric<{ service: AppType }>({
modal: 'editService',
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
modal: 'editApp',
innerProps: {
service: {
app: {
id: uuidv4(),
name: selected[0].Names[0],
name: app.name ? app.name : selected[0].Names[0].substring(1),
url: containerUrl,
appearance: {
iconUrl: '/imgs/logo/logo.png', // TODO: find icon automatically
iconUrl: app.icon ? app.icon : '/imgs/logo/logo.png',
},
network: {
enabledStatusChecker: false,
okStatus: [],
enabledStatusChecker: true,
okStatus: [200],
},
behaviour: {
isOpeningNewTab: true,
externalUrl: '',
},
area: {
type: 'sidebar', // TODO: Set the wrapper automatically
type: 'wrapper',
properties: {
location: 'right',
id: getLowestWrapper()?.id ?? 'default',
},
},
shape: {
lg: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
location: {
x: 0,
y: 0,
},
md: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
},
sm: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
size: {
width: appTileDefinition.minWidth,
height: appTileDefinition.minHeight,
},
},
integration: {
@@ -220,7 +204,9 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
properties: [],
},
},
allowAppNamePropagation: true,
},
size: 'xl',
});
}}
>

View File

@@ -5,7 +5,7 @@ import axios from 'axios';
import Docker from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfigContext } from '../../../../../config/provider';
import { useConfigContext } from '../../config/provider';
import ContainerActionBar from './ContainerActionBar';
import DockerTable from './DockerTable';
@@ -60,6 +60,7 @@ export default function DockerMenuButton(props: any) {
opened={opened}
onClose={() => setOpened(false)}
padding="xl"
position="right"
size="full"
title={<ContainerActionBar selected={selection} reload={reload} />}
>

View File

@@ -118,7 +118,6 @@ export default function DockerTable({
icon={<IconSearch size={14} />}
value={search}
onChange={handleSearchChange}
disabled={usedContainers.length === 0}
/>
<Table ref={ref} captionSide="bottom" highlightOnHover verticalSpacing="sm">
<thead>

View File

@@ -45,13 +45,21 @@ function Put(req: NextApiRequest, res: NextApiResponse) {
(previousProperty) => previousProperty.field === property.field
);
if (property.value !== undefined && property.value !== null) {
Consola.info(
'Detected credential change of private secret. Value will be overwritten in configuration'
);
return {
field: property.field,
type: property.type,
value: property.value,
};
}
return {
field: property.field,
type: property.type,
value:
property.value !== undefined || property.value === null
? property.value
: previousProperty?.value,
value: previousProperty?.value,
};
}),
},

View File

@@ -3,7 +3,7 @@ import { getCookie } from 'cookies-next';
import axios from 'axios';
import Consola from 'consola';
import { getConfig } from '../../../../tools/config/getConfig';
import { MediaType } from '../../../../modules/overseerr/SearchResult';
import type { MediaType } from '../../../../modules/overseerr/SearchResult';
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Get the slug of the request

View File

@@ -17,7 +17,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
} else if (error.code === 'ECONNABORTED') {
res.status(408).json('Request Timeout');
} else {
res.status(500).json('Server Error');
res.status(error.response ? error.response.status : 500).json('Server Error');
}
});
// // Make a request to the URL

View File

@@ -81,7 +81,7 @@
}
.grid-stack > .grid-stack-item > .grid-stack-item-content {
overflow-y: hidden;
overflow-y: auto;
}
.grid-stack.grid-stack-animate {

View File

@@ -1,21 +1,21 @@
export const StatusCodes = [
{ value: '200', label: '200 - OK', group: 'Sucessful responses' },
{ value: '204', label: '204 - No Content', group: 'Sucessful responses' },
{ value: '301', label: '301 - Moved Permanently', group: 'Redirection responses' },
{ value: '302', label: '302 - Found / Moved Temporarily', group: 'Redirection responses' },
{ value: '304', label: '304 - Not Modified', group: 'Redirection responses' },
{ value: '307', label: '307 - Temporary Redirect', group: 'Redirection responses' },
{ value: '308', label: '308 - Permanent Redirect', group: 'Redirection responses' },
{ value: '400', label: '400 - Bad Request', group: 'Client error responses' },
{ value: '401', label: '401 - Unauthorized', group: 'Client error responses' },
{ value: '403', label: '403 - Forbidden', group: 'Client error responses' },
{ value: '404', label: '404 - Not Found', group: 'Client error responses' },
{ value: '405', label: '405 - Method Not Allowed', group: 'Client error responses' },
{ value: '408', label: '408 - Request Timeout', group: 'Client error responses' },
{ value: '410', label: '410 - Gone', group: 'Client error responses' },
{ value: '429', label: '429 - Too Many Requests', group: 'Client error responses' },
{ value: '500', label: '500 - Internal Server Error', group: 'Server error responses' },
{ value: '502', label: '502 - Bad Gateway', group: 'Server error responses' },
{ value: '503', label: '503 - Service Unavailable', group: 'Server error responses' },
{ value: '054', label: '504 - Gateway Timeout Error', group: 'Server error responses' },
{ value: 200, label: '200 - OK', group: 'Sucessful responses' },
{ value: 204, label: '204 - No Content', group: 'Sucessful responses' },
{ value: 301, label: '301 - Moved Permanently', group: 'Redirection responses' },
{ value: 302, label: '302 - Found / Moved Temporarily', group: 'Redirection responses' },
{ value: 304, label: '304 - Not Modified', group: 'Redirection responses' },
{ value: 307, label: '307 - Temporary Redirect', group: 'Redirection responses' },
{ value: 308, label: '308 - Permanent Redirect', group: 'Redirection responses' },
{ value: 400, label: '400 - Bad Request', group: 'Client error responses' },
{ value: 401, label: '401 - Unauthorized', group: 'Client error responses' },
{ value: 403, label: '403 - Forbidden', group: 'Client error responses' },
{ value: 404, label: '404 - Not Found', group: 'Client error responses' },
{ value: 405, label: '405 - Method Not Allowed', group: 'Client error responses' },
{ value: 408, label: '408 - Request Timeout', group: 'Client error responses' },
{ value: 410, label: '410 - Gone', group: 'Client error responses' },
{ value: 429, label: '429 - Too Many Requests', group: 'Client error responses' },
{ value: 500, label: '500 - Internal Server Error', group: 'Server error responses' },
{ value: 502, label: '502 - Bad Gateway', group: 'Server error responses' },
{ value: 503, label: '503 - Service Unavailable', group: 'Server error responses' },
{ value: 504, label: '504 - Gateway Timeout Error', group: 'Server error responses' },
];

View File

@@ -6,6 +6,7 @@ export const dashboardNamespaces = [
'layout/modals/change-position',
'layout/modals/about',
'layout/header/actions/toggle-edit-mode',
'layout/mobile/drawer',
'settings/common',
'settings/general/theme-selector',
'settings/general/config-changer',

View File

@@ -102,6 +102,7 @@ export const portmap = [
{ name: 'nzbget', value: '6789' },
];
//TODO: Fix this to be used in the docker add to homarr button
export const MatchingImages: {
image: string;
type: ServiceType;

View File

@@ -52,12 +52,14 @@ export type ConfigAppIntegrationType = Omit<AppIntegrationType, 'properties'> &
};
export type AppIntegrationPropertyType = {
type: 'private' | 'public';
type: AppIntegrationPropertyAccessabilityType;
field: IntegrationField;
value?: string | null;
isDefined: boolean;
};
export type AppIntegrationPropertyAccessabilityType = 'private' | 'public';
type ConfigAppIntegrationPropertyType = Omit<AppIntegrationPropertyType, 'isDefined'>;
export type IntegrationField = 'apiKey' | 'password' | 'username';

View File

@@ -18,7 +18,7 @@ export const BitTorrrentQueueItem = ({ torrent }: BitTorrentQueueItemProps) => {
return (
<tr key={torrent.id}>
<td>
<Tooltip position="top" label={torrent.name}>
<Tooltip position="top" withinPortal label={torrent.name}>
<Text
style={{
maxWidth: '30vw',

View File

@@ -1,6 +1,7 @@
import { NormalizedTorrent, TorrentState } from '@ctrl/shared-torrent';
import { NormalizedTorrent } from '@ctrl/shared-torrent';
import {
Center,
Flex,
Group,
Loader,
ScrollArea,
@@ -12,6 +13,9 @@ import {
} from '@mantine/core';
import { useElementSize } from '@mantine/hooks';
import { IconFileDownload } from '@tabler/icons';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { useConfigContext } from '../../config/provider';
@@ -21,6 +25,9 @@ import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { BitTorrrentQueueItem } from './BitTorrentQueueItem';
dayjs.extend(duration);
dayjs.extend(relativeTime);
const downloadAppTypes: AppIntegrationType['type'][] = ['deluge', 'qBittorrent', 'transmission'];
const definition = defineWidget({
@@ -35,6 +42,13 @@ const definition = defineWidget({
type: 'switch',
defaultValue: true,
},
refreshInterval: {
type: 'slider',
defaultValue: 1,
min: 1,
max: 60,
step: 1,
},
},
gridstack: {
minWidth: 4,
@@ -62,7 +76,10 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
[];
const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id);
const { data, isFetching, isError } = useGetTorrentData({ appId: selectedAppId! });
const { data, isError, isInitialLoading, dataUpdatedAt } = useGetTorrentData({
appId: selectedAppId!,
refreshInterval: widget.properties.refreshInterval * 1000,
});
useEffect(() => {
if (!selectedAppId && downloadApps.length) {
@@ -92,9 +109,15 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
);
}
if (isFetching) {
if (isInitialLoading) {
return (
<Stack align="center">
<Stack
align="center"
justify="center"
style={{
height: '100%',
}}
>
<Loader />
<Stack align="center" spacing={0}>
<Text>{t('card.loading.title')}</Text>
@@ -124,26 +147,35 @@ function BitTorrentTile({ widget }: BitTorrentTileProps) {
return true;
};
const difference = new Date().getTime() - dataUpdatedAt;
const duration = dayjs.duration(difference, 'ms');
const humanizedDuration = duration.humanize();
return (
<ScrollArea sx={{ height: 300, width: '100%' }}>
<Table highlightOnHover p="sm">
<thead>
<tr>
<th>{t('card.table.header.name')}</th>
<th>{t('card.table.header.size')}</th>
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.download')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.upload')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.estimatedTimeOfArrival')}</th>}
<th>{t('card.table.header.progress')}</th>
</tr>
</thead>
<tbody>
{data.filter(filter).map((item: NormalizedTorrent, index: number) => (
<BitTorrrentQueueItem key={index} torrent={item} />
))}
</tbody>
</Table>
</ScrollArea>
<Flex direction="column" sx={{ height: '100%' }}>
<ScrollArea sx={{ height: '100%', width: '100%' }}>
<Table highlightOnHover p="sm">
<thead>
<tr>
<th>{t('card.table.header.name')}</th>
<th>{t('card.table.header.size')}</th>
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.download')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.upload')}</th>}
{width > MIN_WIDTH_MOBILE && <th>{t('card.table.header.estimatedTimeOfArrival')}</th>}
<th>{t('card.table.header.progress')}</th>
</tr>
</thead>
<tbody>
{data.filter(filter).map((item: NormalizedTorrent, index: number) => (
<BitTorrrentQueueItem key={index} torrent={item} />
))}
</tbody>
</Table>
</ScrollArea>
<Text color="dimmed" size="xs">
Last updated {humanizedDuration} ago
</Text>
</Flex>
);
}

View File

@@ -54,9 +54,11 @@ function DashDotTile({ widget }: DashDotTileProps) {
const { classes } = useDashDotTileStyles();
const { t } = useTranslation('modules/dashdot');
const dashDotUrl = widget?.properties.url;
const dashDotUrl = widget.properties.url;
const { data: info } = useDashDotInfo({ dashDotUrl });
const { data: info } = useDashDotInfo({
dashDotUrl,
});
const graphs = widget?.properties.graphs.map((g) => ({
id: g,
@@ -112,6 +114,7 @@ function DashDotTile({ widget }: DashDotTileProps) {
const useDashDotInfo = ({ dashDotUrl }: { dashDotUrl: string }) => {
const { name: configName } = useConfigContext();
return useQuery({
refetchInterval: 50000,
queryKey: [
'dashdot/info',
{

View File

@@ -1,4 +1,4 @@
import { Center, Stack, Text, Title } from '@mantine/core';
import { Stack, Text, Title } from '@mantine/core';
import { IconClock } from '@tabler/icons';
import dayjs from 'dayjs';
import { useEffect, useRef, useState } from 'react';

View File

@@ -5,6 +5,4 @@ import { IWidgetDefinition } from './widgets';
// The options of IWidgetDefinition are so heavily typed that it even used 'true' as type
export const defineWidget = <TKey extends string, TOptions extends IWidgetDefinition<TKey>>(
options: TOptions
) => {
return options;
};
) => options;

View File

@@ -46,16 +46,20 @@ const definition = defineWidget({
},
});
export type IWeatherWidget = IWidget<typeof definition['id'], typeof definition>;
export type IUsenetWidget = IWidget<typeof definition['id'], typeof definition>;
interface UseNetTileProps {}
interface UseNetTileProps {
widget: IUsenetWidget;
}
function UseNetTile({}: UseNetTileProps) {
function UseNetTile({ widget }: UseNetTileProps) {
const { t } = useTranslation('modules/usenet');
const { config } = useConfigContext();
const downloadApps =
config?.apps.filter((x) => x.integration && downloadAppTypes.includes(x.integration.type)) ??
[];
const { ref, width, height } = useElementSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id);
const { data } = useGetUsenetInfo({ appId: selectedAppId! });
@@ -84,9 +88,6 @@ function UseNetTile({}: UseNetTileProps) {
return null;
}
const { ref, width, height } = useElementSize();
const MIN_WIDTH_MOBILE = useMantineTheme().breakpoints.xs;
return (
<Tabs keepMounted={false} defaultValue="queue">
<Tabs.List ref={ref} mb="md" style={{ flex: 1 }} grow>

View File

@@ -4,8 +4,8 @@ import {
Code,
Group,
Pagination,
ScrollArea,
Skeleton,
Stack,
Table,
Text,
Title,
@@ -28,7 +28,7 @@ interface UsenetHistoryListProps {
appId: string;
}
const PAGE_SIZE = 10;
const PAGE_SIZE = 13;
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ appId }) => {
const [page, setPage] = useState(1);
@@ -39,7 +39,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
const { data, isLoading, isError, error } = useGetUsenetHistory({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
appId: appId,
appId,
});
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
@@ -81,50 +81,49 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
}
return (
<>
<ScrollArea style={{ flex: 1 }}>
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
<thead>
<tr>
<th>{t('modules/usenet:history.header.name')}</th>
<th style={{ width: 100 }}>{t('modules/usenet:history.header.size')}</th>
<Stack justify="space-around" spacing="xs">
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
<thead>
<tr>
<th>{t('modules/usenet:history.header.name')}</th>
<th style={{ width: 100 }}>{t('modules/usenet:history.header.size')}</th>
{durationBreakpoint < width ? (
<th style={{ width: 200 }}>{t('modules/usenet:history.header.duration')}</th>
) : null}
</tr>
</thead>
<tbody>
{data.items.map((history) => (
<tr key={history.id}>
<td>
<Tooltip position="top" label={history.name}>
<Text
size="xs"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{history.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(history.size)}</Text>
</td>
{durationBreakpoint < width ? (
<th style={{ width: 200 }}>{t('modules/usenet:history.header.duration')}</th>
<td>
<Text size="xs">{parseDuration(history.time, t)}</Text>
</td>
) : null}
</tr>
</thead>
<tbody>
{data.items.map((history) => (
<tr key={history.id}>
<td>
<Tooltip position="top" label={history.name}>
<Text
size="xs"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{history.name}
</Text>
</Tooltip>
</td>
<td>
<Text size="xs">{humanFileSize(history.size)}</Text>
</td>
{durationBreakpoint < width ? (
<td>
<Text size="xs">{parseDuration(history.time, t)}</Text>
</td>
) : null}
</tr>
))}
</tbody>
</Table>
</ScrollArea>
))}
</tbody>
</Table>
{totalPages > 1 && (
<Pagination
noWrap
size="sm"
position="center"
mt="md"
@@ -133,6 +132,6 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ a
onChange={setPage}
/>
)}
</>
</Stack>
);
};

View File

@@ -1,6 +1,7 @@
import {
ActionIcon,
Alert,
Button,
Center,
Code,
Group,
@@ -8,6 +9,7 @@ import {
Progress,
ScrollArea,
Skeleton,
Stack,
Table,
Text,
Title,
@@ -30,7 +32,7 @@ interface UsenetQueueListProps {
appId: string;
}
const PAGE_SIZE = 10;
const PAGE_SIZE = 13;
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId }) => {
const theme = useMantineTheme();
@@ -38,13 +40,13 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
const progressbarBreakpoint = theme.breakpoints.xs;
const progressBreakpoint = 400;
const sizeBreakpoint = 300;
const { ref, width, height } = useElementSize();
const { ref, width } = useElementSize();
const [page, setPage] = useState(1);
const { data, isLoading, isError, error } = useGetUsenetDownloads({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
appId: appId,
appId,
});
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);
@@ -85,103 +87,102 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId
);
}
// TODO: Set ScollArea dynamic height based on the widget size
return (
<>
<ScrollArea style={{ flex: 1 }}>
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
<thead>
<tr>
<th style={{ width: 32 }} />
<th style={{ width: '75%' }}>{t('queue.header.name')}</th>
<Stack justify="space-around" spacing="xs">
<Table highlightOnHover style={{ tableLayout: 'fixed' }} ref={ref}>
<thead>
<tr>
<th style={{ width: 32 }} />
<th style={{ width: '75%' }}>{t('queue.header.name')}</th>
{sizeBreakpoint < width ? (
<th style={{ width: 100 }}>{t('queue.header.size')}</th>
) : null}
<th style={{ width: 60 }}>{t('queue.header.eta')}</th>
{progressBreakpoint < width ? (
<th style={{ width: progressbarBreakpoint > width ? 100 : 200 }}>
{t('queue.header.progress')}
</th>
) : null}
</tr>
</thead>
<tbody>
{data.items.map((nzb) => (
<tr key={nzb.id}>
<td>
{nzb.state === 'paused' ? (
<Tooltip label="NOT IMPLEMENTED">
<ActionIcon color="gray" variant="subtle" radius="xl" size="sm">
<IconPlayerPlay size="16" />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label="NOT IMPLEMENTED">
<ActionIcon color="primary" variant="subtle" radius="xl" size="sm">
<IconPlayerPause size="16" />
</ActionIcon>
</Tooltip>
)}
</td>
<td>
<Tooltip position="top" label={nzb.name}>
<Text
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
size="xs"
color={nzb.state === 'paused' ? 'dimmed' : undefined}
>
{nzb.name}
</Text>
</Tooltip>
</td>
{sizeBreakpoint < width ? (
<th style={{ width: 100 }}>{t('queue.header.size')}</th>
<td>
<Text size="xs">{humanFileSize(nzb.size)}</Text>
</td>
) : null}
<th style={{ width: 60 }}>{t('queue.header.eta')}</th>
<td>
{nzb.eta <= 0 ? (
<Text size="xs" color="dimmed">
{t('queue.paused')}
</Text>
) : (
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
)}
</td>
{progressBreakpoint < width ? (
<th style={{ width: progressbarBreakpoint > width ? 100 : 200 }}>
{t('queue.header.progress')}
</th>
<td style={{ display: 'flex', alignItems: 'center' }}>
<Text mr="sm" style={{ whiteSpace: 'nowrap' }}>
{nzb.progress.toFixed(1)}%
</Text>
{width > progressbarBreakpoint ? (
<Progress
radius="lg"
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
value={nzb.progress}
size="lg"
style={{ width: '100%' }}
/>
) : null}
</td>
) : null}
</tr>
</thead>
<tbody>
{data.items.map((nzb) => (
<tr key={nzb.id}>
<td>
{nzb.state === 'paused' ? (
<Tooltip label="NOT IMPLEMENTED">
<ActionIcon color="gray" variant="subtle" radius="xl" size="sm">
<IconPlayerPlay size="16" />
</ActionIcon>
</Tooltip>
) : (
<Tooltip label="NOT IMPLEMENTED">
<ActionIcon color="primary" variant="subtle" radius="xl" size="sm">
<IconPlayerPause size="16" />
</ActionIcon>
</Tooltip>
)}
</td>
<td>
<Tooltip position="top" label={nzb.name}>
<Text
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
size="xs"
color={nzb.state === 'paused' ? 'dimmed' : undefined}
>
{nzb.name}
</Text>
</Tooltip>
</td>
{sizeBreakpoint < width ? (
<td>
<Text size="xs">{humanFileSize(nzb.size)}</Text>
</td>
) : null}
<td>
{nzb.eta <= 0 ? (
<Text size="xs" color="dimmed">
{t('queue.paused')}
</Text>
) : (
<Text size="xs">{dayjs.duration(nzb.eta, 's').format('H:mm:ss')}</Text>
)}
</td>
{progressBreakpoint < width ? (
<td style={{ display: 'flex', alignItems: 'center' }}>
<Text mr="sm" style={{ whiteSpace: 'nowrap' }}>
{nzb.progress.toFixed(1)}%
</Text>
{width > progressbarBreakpoint ? (
<Progress
radius="lg"
color={nzb.eta > 0 ? theme.primaryColor : 'lightgrey'}
value={nzb.progress}
size="lg"
style={{ width: '100%' }}
/>
) : null}
</td>
) : null}
</tr>
))}
</tbody>
</Table>
</ScrollArea>
))}
</tbody>
</Table>
{totalPages > 1 && (
<Pagination
noWrap
size="sm"
position="center"
mt="md"
total={totalPages}
page={page}
onChange={setPage}
/>
)}
</>
</Stack>
);
};

View File

@@ -1,6 +1,5 @@
import { IconSun, TablerIcon } from '@tabler/icons';
import { TablerIcon } from '@tabler/icons';
import React from 'react';
import { BaseTileProps } from '../components/Dashboard/Tiles/type';
// Type of widgets which are safed to config
export type IWidget<TKey extends string, TDefinition extends IWidgetDefinition> = {
@@ -32,6 +31,7 @@ export type IWidgetOptionValue =
| IMultiSelectOptionValue
| ISwitchOptionValue
| ITextInputOptionValue
| ISliderInputOptionValue
| INumberInputOptionValue;
// will show a multi-select with specified data
@@ -56,7 +56,16 @@ export type ITextInputOptionValue = {
// will show a number-input
export type INumberInputOptionValue = {
type: 'number';
defaultValue: string;
defaultValue: number;
};
// will show a slider-input
export type ISliderInputOptionValue = {
type: 'slider';
defaultValue: number;
min: number;
max: number;
step: number;
};
// is used to type the widget definitions which will be used to display all widgets