feat: remove unused appshelf components

This commit is contained in:
Manuel Ruwe
2022-12-08 22:17:33 +01:00
parent afbf2d2d2f
commit b6bf6cc86b
12 changed files with 152 additions and 581 deletions

View File

@@ -1,460 +0,0 @@
import {
ActionIcon,
Anchor,
Button,
Center,
Group,
Image,
LoadingOverlay,
Modal,
PasswordInput,
Select,
Space,
Stack,
Switch,
Tabs,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { IconApps } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { useConfig } from '../../tools/state';
import { tryMatchPort, ServiceTypeList, Config } from '../../tools/types';
import apiKeyPaths from './apiKeyPaths.json';
import Tip from '../layout/Tip';
export function AddItemShelfButton(props: any) {
const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false);
const { t } = useTranslation('layout/add-service-app-shelf');
return (
<>
<Modal
size="xl"
radius="md"
title={<Title order={3}>{t('modal.title')}</Title>}
opened={props.opened || opened}
onClose={() => setOpened(false)}
>
<AddAppShelfItemForm config={config} setConfig={setConfig} setOpened={setOpened} />
</Modal>
<Tooltip withinPortal label={t('actionIcon.tooltip')}>
<ActionIcon
variant="default"
radius="md"
size="xl"
color="blue"
style={props.style}
onClick={() => setOpened(true)}
>
<IconApps />
</ActionIcon>
</Tooltip>
</>
);
}
function MatchIcon(name: string | undefined, form: any) {
if (name === undefined || name === '') return null;
fetch(
`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${name
.replace(/\s+/g, '-')
.toLowerCase()
.replace(/^dash\.$/, 'dashdot')}.png`
).then((res) => {
if (res.ok) {
form.setFieldValue('icon', res.url);
}
});
return false;
}
function MatchService(name: string, form: any) {
const service = ServiceTypeList.find((s) => s.toLowerCase() === name.toLowerCase());
if (service) {
form.setFieldValue('type', service);
}
}
const DEFAULT_ICON = '/imgs/favicon/favicon.png';
interface AddAppShelfItemFormProps {
setOpened: (b: boolean) => void;
config: Config;
setConfig: (config: Config) => void;
// Any other props you want to pass to the form
[key: string]: any;
}
export function AddAppShelfItemForm(props: AddAppShelfItemFormProps) {
const { setOpened, config, setConfig } = props;
// Only get config and setConfig from useCOnfig if they are not present in props
const [isLoading, setLoading] = useState(false);
const { t } = useTranslation('layout/add-service-app-shelf');
// Extract all the categories from the services in config
const InitialCategories = config.services.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
return acc;
}, [] as string[]);
const [categories, setCategories] = useState<string[]>(InitialCategories);
const form = useForm({
initialValues: {
id: props.id ?? uuidv4(),
type: props.type ?? 'Other',
category: props.category ?? null,
name: props.name ?? '',
icon: props.icon ?? DEFAULT_ICON,
url: props.url ?? '',
apiKey: props.apiKey ?? undefined,
username: props.username ?? undefined,
password: props.password ?? undefined,
openedUrl: props.openedUrl ?? undefined,
ping: props.ping ?? true,
newTab: props.newTab ?? true,
},
validate: {
apiKey: () => null,
// Validate icon with a regex
icon: (value: string) =>
// Disable matching to allow any values
null,
// Validate url with a regex http/https
url: (value: string) => {
try {
const _isValid = new URL(value);
} catch (e) {
return t('modal.form.validation.invalidUrl');
}
return null;
},
},
});
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => {
if (
form.values.name !== debounced ||
form.values.icon !== DEFAULT_ICON ||
form.values.type !== 'Other'
) {
return;
}
MatchIcon(form.values.name, form);
MatchService(form.values.name, form);
tryMatchPort(form.values.name, form);
}, [debounced]);
// Try to set const hostname to new URL(form.values.url).hostname)
// If it fails, set it to the form.values.url
let hostname = form.values.url;
try {
hostname = new URL(form.values.url).origin;
} catch (e) {
// Do nothing
}
return (
<>
<Center mb="lg">
<Image
height={120}
width={120}
fit="contain"
src={form.values.icon}
alt="Placeholder"
withPlaceholder
/>
</Center>
<form
onSubmit={form.onSubmit(() => {
const newForm = { ...form.values };
if (newForm.newTab === true) newForm.newTab = undefined;
if (newForm.openedUrl === '') newForm.openedUrl = undefined;
if (newForm.category === null) newForm.category = undefined;
if (newForm.ping === true) newForm.ping = undefined;
// If service already exists, update it.
if (config.services && config.services.find((s) => s.id === newForm.id)) {
setConfig({
...config,
// replace the found item by matching ID
services: config.services.map((s) => {
if (s.id === newForm.id) {
return {
...newForm,
};
}
return s;
}),
});
} else {
setConfig({
...config,
services: [...config.services, newForm],
});
}
setOpened(false);
form.reset();
})}
>
<Tabs defaultValue="Options">
<Tabs.List grow>
<Tabs.Tab value="Options">{t('modal.tabs.options.title')}</Tabs.Tab>
<Tabs.Tab value="Advanced Options">{t('modal.tabs.advancedOptions.title')}</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="Options">
<Space h="sm" />
<Stack>
<TextInput
required
label={t('modal.tabs.options.form.serviceName.label')}
placeholder={t('modal.tabs.options.form.serviceName.placeholder')}
{...form.getInputProps('name')}
/>
<TextInput
required
label={t('modal.tabs.options.form.iconUrl.label')}
placeholder={DEFAULT_ICON}
{...form.getInputProps('icon')}
/>
<TextInput
required
label={t('modal.tabs.options.form.serviceUrl.label')}
placeholder="http://localhost:7575"
{...form.getInputProps('url')}
/>
<TextInput
label={t('modal.tabs.options.form.onClickUrl.label')}
placeholder="http://sonarr.example.com"
{...form.getInputProps('openedUrl')}
/>
<Select
label={t('modal.tabs.options.form.serviceType.label')}
defaultValue={t('modal.tabs.options.form.serviceType.defaultValue')}
placeholder={t('modal.tabs.options.form.serviceType.placeholder')}
required
searchable
data={ServiceTypeList}
{...form.getInputProps('type')}
/>
<Select
label={t('modal.tabs.options.form.category.label')}
data={categories}
placeholder={t('modal.tabs.options.form.category.placeholder')}
nothingFound={t('modal.tabs.options.form.category.nothingFound')}
searchable
clearable
creatable
onCreate={(query) => {
const item = { value: query, label: query };
setCategories([...InitialCategories, query]);
return item;
}}
getCreateLabel={(query) =>
t('modal.tabs.options.form.category.createLabel', {
query,
})
}
{...form.getInputProps('category')}
/>
<LoadingOverlay visible={isLoading} />
{(form.values.type === 'Sonarr' ||
form.values.type === 'Radarr' ||
form.values.type === 'Lidarr' ||
form.values.type === 'Overseerr' ||
form.values.type === 'Jellyseerr' ||
form.values.type === 'Readarr' ||
form.values.type === 'Sabnzbd') && (
<>
<TextInput
required
label={t('modal.tabs.options.form.integrations.apiKey.label')}
placeholder={t('modal.tabs.options.form.integrations.apiKey.placeholder')}
value={form.values.apiKey}
onChange={(event) => {
form.setFieldValue('apiKey', event.currentTarget.value);
}}
error={
form.errors.apiKey &&
t('modal.tabs.options.form.integrations.apiKey.validation.noKey')
}
/>
<Tip>
{t('modal.tabs.options.form.integrations.apiKey.tip.text')}{' '}
<Anchor
target="_blank"
weight="bold"
style={{ fontStyle: 'inherit', fontSize: 'inherit' }}
href={`${hostname}/${
apiKeyPaths[form.values.type as keyof typeof apiKeyPaths]
}`}
>
{t('modal.tabs.options.form.integrations.apiKey.tip.link')}
</Anchor>
</Tip>
</>
)}
{form.values.type === 'qBittorrent' && (
<>
<TextInput
label={t('modal.tabs.options.form.integrations.qBittorrent.username.label')}
placeholder={t(
'modal.tabs.options.form.integrations.qBittorrent.username.placeholder'
)}
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={
form.errors.username &&
t(
'modal.tabs.options.form.integrations.qBittorrent.username.validation.invalidUsername'
)
}
/>
<PasswordInput
label={t('modal.tabs.options.form.integrations.qBittorrent.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.qBittorrent.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.qBittorrent.password.validation.invalidPassword'
)
}
/>
</>
)}
{form.values.type === 'Deluge' && (
<>
<PasswordInput
label={t('modal.tabs.options.form.integrations.deluge.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.deluge.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.deluge.password.validation.invalidPassword'
)
}
/>
</>
)}
{form.values.type === 'Transmission' && (
<>
<TextInput
label={t('modal.tabs.options.form.integrations.transmission.username.label')}
placeholder={t(
'modal.tabs.options.form.integrations.transmission.username.placeholder'
)}
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={
form.errors.username &&
t(
'modal.tabs.options.form.integrations.transmission.username.validation.invalidUsername'
)
}
/>
<PasswordInput
label={t('modal.tabs.options.form.integrations.transmission.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.transmission.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.transmission.password.validation.invalidPassword'
)
}
/>
</>
)}
{form.values.type === 'NZBGet' && (
<>
<TextInput
label={t('modal.tabs.options.form.integrations.nzbget.username.label')}
placeholder={t(
'modal.tabs.options.form.integrations.nzbget.username.placeholder'
)}
value={form.values.username}
onChange={(event) => {
form.setFieldValue('username', event.currentTarget.value);
}}
error={
form.errors.username &&
t(
'modal.tabs.options.form.integrations.nzbget.username.validation.invalidUsername'
)
}
/>
<PasswordInput
label={t('modal.tabs.options.form.integrations.nzbget.password.label')}
placeholder={t(
'modal.tabs.options.form.integrations.nzbget.password.placeholder'
)}
value={form.values.password}
onChange={(event) => {
form.setFieldValue('password', event.currentTarget.value);
}}
error={
form.errors.password &&
t(
'modal.tabs.options.form.integrations.nzbget.password.validation.invalidPassword'
)
}
/>
</>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="Advanced Options">
<Space h="sm" />
<Stack>
<Switch
label="Ping service"
defaultChecked={form.values.ping}
{...form.getInputProps('ping')}
/>
<Switch
label={t('modal.tabs.advancedOptions.form.openServiceInNewTab.label')}
defaultChecked={form.values.newTab}
{...form.getInputProps('newTab')}
/>
</Stack>
</Tabs.Panel>
</Tabs>
<Group grow position="center" mt="xl">
<Button type="submit">
{props.message ?? t('modal.tabs.advancedOptions.form.buttons.submit.content')}
</Button>
</Group>
</form>
</>
);
}

View File

@@ -15,7 +15,7 @@ import { useState } from 'react';
import PingComponent from '../../modules/ping/PingModule';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import AppShelfMenu from './AppShelfMenu';
import { TileMenu } from '../Dashboard/Menu/TileMenu';
const useStyles = createStyles((theme) => ({
item: {
@@ -104,7 +104,7 @@ export function AppShelfItem(props: any) {
opacity: hovering ? 1 : 0,
}}
>
<AppShelfMenu service={service} />
<TileMenu service={service} />
</motion.div>
</Card.Section>
<Card.Section>

View File

@@ -1,86 +0,0 @@
import { ActionIcon, Menu, Modal, Text } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { useState } from 'react';
import { IconCheck as Check, IconEdit as Edit, IconMenu, IconTrash as Trash } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfig } from '../../tools/state';
import { serviceItem } from '../../tools/types';
import { AddAppShelfItemForm } from './AddAppShelfItem';
import { useColorTheme } from '../../tools/color';
export default function AppShelfMenu(props: any) {
const { service }: { service: serviceItem } = props;
const { config, setConfig } = useConfig();
const { secondaryColor } = useColorTheme();
const { t } = useTranslation('layout/app-shelf-menu');
const [opened, setOpened] = useState(false);
return (
<>
<Modal
size="xl"
radius="md"
opened={props.opened || opened}
onClose={() => setOpened(false)}
title={t('modal.title')}
>
<AddAppShelfItemForm
config={config}
setConfig={setConfig}
setOpened={setOpened}
{...service}
message={t('modal.buttons.save')}
/>
</Modal>
<Menu
withinPortal
width={150}
shadow="xl"
withArrow
radius="md"
position="right"
styles={{
dropdown: {
// Add shadow and elevation to the body
boxShadow: '0 0 14px 14px rgba(0, 0, 0, 0.05)',
},
}}
>
<Menu.Target>
<ActionIcon style={{}}>
<IconMenu />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('menu.labels.settings')}</Menu.Label>
<Menu.Item color={secondaryColor} icon={<Edit />} onClick={() => setOpened(true)}>
{t('menu.actions.edit')}
</Menu.Item>
<Menu.Label>{t('menu.labels.dangerZone')}</Menu.Label>
<Menu.Item
color="red"
onClick={(e: any) => {
setConfig({
...config,
services: config.services.filter((s) => s.id !== service.id),
});
showNotification({
autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <Check />,
message: undefined,
});
}}
icon={<Trash />}
>
{t('menu.actions.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
}

View File

@@ -0,0 +1,76 @@
import { ActionIcon, Menu, Text } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconCheck, IconEdit, IconMenu, IconTrash } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../config/provider';
import { useConfigStore } from '../../../config/store';
import { useColorTheme } from '../../../tools/color';
import { ServiceType } from '../../../types/service';
interface TileMenuProps {
service: ServiceType;
}
export const TileMenu = ({ service }: TileMenuProps) => {
const { secondaryColor } = useColorTheme();
const { t } = useTranslation('layout/app-shelf-menu');
const updateConfig = useConfigStore((x) => x.updateConfig);
const { name: configName } = useConfigContext();
return (
<Menu withinPortal width={150} shadow="xl" withArrow radius="md" position="right">
<Menu.Target>
<ActionIcon>
<IconMenu />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('menu.labels.settings')}</Menu.Label>
<Menu.Item
color={secondaryColor}
icon={<IconEdit />}
onClick={() =>
openContextModal({
modal: 'changeTilePosition',
innerProps: {
type: 'service',
tile: service,
},
})
}
>
{t('menu.actions.edit')}
</Menu.Item>
<Menu.Label>{t('menu.labels.dangerZone')}</Menu.Label>
<Menu.Item
color="red"
onClick={(e: any) => {
if (!configName) {
return;
}
updateConfig(configName, (previousConfig) => ({
...previousConfig,
services: previousConfig.services.filter((x) => x.id !== service.id),
})).then(() => {
showNotification({
autoClose: 5000,
title: (
<Text>
Service <b>{service.name}</b> removed successfully!
</Text>
),
color: 'green',
icon: <IconCheck />,
message: undefined,
});
});
}}
icon={<IconTrash />}
>
{t('menu.actions.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};

View File

@@ -0,0 +1,31 @@
import { Grid, NumberInput } from '@mantine/core';
import { useForm } from '@mantine/form';
import { ContextModalProps } from '@mantine/modals';
import { TileBaseType } from '../../../../types/tile';
export const ChangePositionModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ type: 'service' | 'type'; tile: TileBaseType }>) => {
const form = useForm({
initialValues: {
area: innerProps.tile.area,
shape: innerProps.tile.shape,
},
});
return (
<>
<Grid>
<Grid.Col xs={12} md={6}>
<NumberInput label="X Position" {...form.getInputProps('area.tile.shape.location.x')} />
</Grid.Col>
<Grid.Col xs={12} md={6}>
<NumberInput label="Y Position" {...form.getInputProps('area.tile.shape.location.y')} />
</Grid.Col>
</Grid>
</>
);
};

View File

@@ -2,9 +2,6 @@ import { ActionIcon, Tooltip } from '@mantine/core';
import { openContextModal } from '@mantine/modals';
import { IconApps } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { v4 as uuidv4 } from 'uuid';
import { openContextModalGeneric } from '../../../../../tools/mantineModalManagerExtensions';
import { ServiceType } from '../../../../../types/service';
export const AddElementAction = () => {
const { t } = useTranslation('layout/add-service-app-shelf');

View File

@@ -1,6 +1,4 @@
import { Box, createStyles, Group, Header as MantineHeader } from '@mantine/core';
import { AddItemShelfButton } from '../../AppShelf/AddAppShelfItem';
import { Logo } from '../Logo';
import { useCardStyles } from '../useCardStyles';
import { AddElementAction } from './Actions/AddElementAction/AddElementAction';
@@ -22,7 +20,6 @@ export function Header(props: any) {
</Box>
<Group position="right" noWrap>
<Search />
<AddItemShelfButton />
<AddElementAction />
<ToolsMenu />
<SettingsMenu />

View File

@@ -16,9 +16,11 @@ import Dockerode from 'dockerode';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { TFunction } from 'react-i18next';
import { AddAppShelfItemForm } from '../../components/AppShelf/AddAppShelfItem';
import { v4 as uuidv4 } from 'uuid';
import { tryMatchService } from '../../tools/addToHomarr';
import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions';
import { useConfig } from '../../tools/state';
import { ServiceType } from '../../types/service';
let t: TFunction<'modules/docker', undefined>;
@@ -160,21 +162,40 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
radius="md"
disabled={selected.length === 0 || selected.length > 1}
onClick={() => {
openModal({
size: 'xl',
modalId: selected.at(0)!.Id,
radius: 'md',
title: t('actionBar.addService.title'),
zIndex: 500,
children: (
<AddAppShelfItemForm
setConfig={setConfig}
config={config}
setOpened={() => closeModal(selected.at(0)!.Id)}
message={t('actionBar.addService.message')}
{...tryMatchService(selected.at(0)!)}
/>
),
const containerUrl = `http://localhost:${selected[0].Ports[0].PublicPort}`;
openContextModalGeneric<{ service: ServiceType }>({
modal: 'editService',
innerProps: {
service: {
id: uuidv4(),
name: selected[0].Names[0],
url: containerUrl,
appearance: {
iconUrl: '/imgs/logo/logo.png', // TODO: find icon automatically
},
network: {
enabledStatusChecker: false,
okStatus: [],
},
behaviour: {
isOpeningNewTab: true,
onClickUrl: '',
},
area: {
type: 'wrapper', // TODO: Set the wrapper automatically
},
shape: {
location: {
x: 0,
y: 0,
},
size: {
height: 1,
width: 1,
},
},
},
},
});
}}
>

View File

@@ -20,7 +20,6 @@ import { NormalizedTorrent } from '@ctrl/shared-torrent';
import { useTranslation } from 'next-i18next';
import { IModule } from '../ModuleTypes';
import { useConfig } from '../../tools/state';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
import { useSetSafeInterval } from '../../tools/hooks/useSetSafeInterval';
import { humanFileSize } from '../../tools/humanFileSize';
@@ -93,7 +92,6 @@ export default function TorrentsComponent() {
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
<AddItemShelfButton />
</Group>
</Stack>
);

View File

@@ -8,7 +8,6 @@ import { useTranslation } from 'next-i18next';
import { Datum, ResponsiveLine } from '@nivo/line';
import { useListState } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
import { useConfig } from '../../tools/state';
import { humanFileSize } from '../../tools/humanFileSize';
import { IModule } from '../ModuleTypes';
@@ -84,11 +83,6 @@ export default function TotalDownloadsComponent() {
<Group>
<Title order={4}>{t('card.errors.noDownloadClients.title')}</Title>
<div>
<AddItemShelfButton
style={{
float: 'inline-end',
}}
/>
{t('card.errors.noDownloadClients.text')}
</div>
</Group>

View File

@@ -22,7 +22,6 @@ import { UsenetHistoryList } from './UsenetHistoryList';
import { useGetServiceByType } from '../../tools/hooks/useGetServiceByType';
import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../tools/hooks/api';
import { humanFileSize } from '../../tools/humanFileSize';
import { AddItemShelfButton } from '../../components/AppShelf/AddAppShelfItem';
dayjs.extend(duration);
@@ -49,7 +48,6 @@ export const UsenetComponent: FunctionComponent = () => {
<Title order={3}>{t('card.errors.noDownloadClients.title')}</Title>
<Group>
<Text>{t('card.errors.noDownloadClients.text')}</Text>
<AddItemShelfButton />
</Group>
</Stack>
);

View File

@@ -9,6 +9,7 @@ import { appWithTranslation } from 'next-i18next';
import { AppProps } from 'next/app';
import Head from 'next/head';
import { useState } from 'react';
import { ChangePositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangePositionModal';
import { EditServiceModal } from '../components/Dashboard/Modals/EditService/EditServiceModal';
import { SelectElementModal } from '../components/Dashboard/Modals/SelectElement/SelectElementModal';
import { ConfigProvider } from '../config/provider';
@@ -79,7 +80,11 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
<NotificationsProvider limit={4} position="bottom-left">
<ConfigProvider>
<ModalsProvider
modals={{ editService: EditServiceModal, selectElement: SelectElementModal }}
modals={{
editService: EditServiceModal,
selectElement: SelectElementModal,
changeTilePosition: ChangePositionModal,
}}
>
<Component {...pageProps} />
</ModalsProvider>