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,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} />}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -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} />}
|
||||
>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item > .grid-stack-item-content {
|
||||
overflow-y: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
15
src/widgets/widgets.d.ts
vendored
15
src/widgets/widgets.d.ts
vendored
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user