🎨 Rename "services" to "apps" in entire project

This commit is contained in:
Manuel Ruwe
2022-12-18 22:27:01 +01:00
parent 1e0a90f2ac
commit 661c05bc50
69 changed files with 661 additions and 495 deletions

View File

@@ -0,0 +1,57 @@
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
import { IconSelector } from './IconSelector/IconSelector';
interface AppearanceTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
disallowAppNameProgagation: () => void;
allowAppNamePropagation: boolean;
}
export const AppearanceTab = ({
form,
disallowAppNameProgagation,
allowAppNamePropagation,
}: AppearanceTabProps) => {
const { t } = useTranslation('');
const { classes } = useStyles();
return (
<Tabs.Panel value="appearance" pt="lg">
<Flex gap={5}>
<TextInput
defaultValue={form.values.appearance.iconUrl}
className={classes.textInput}
icon={<DebouncedAppIcon form={form} width={20} height={20} />}
label="App Icon"
description="Logo of your app displayed in your dashboard. Must return a body content containg an image"
variant="default"
withAsterisk
required
{...form.getInputProps('appearance.iconUrl')}
/>
<IconSelector
onChange={(item) => {
form.setValues({
appearance: {
iconUrl: item.url,
},
});
disallowAppNameProgagation();
}}
allowAppNamePropagation={allowAppNamePropagation}
form={form}
/>
</Flex>
</Tabs.Panel>
);
};
const useStyles = createStyles(() => ({
textInput: {
flexGrow: 1,
},
}));

View File

@@ -0,0 +1,144 @@
/* eslint-disable @next/next/no-img-element */
import {
ActionIcon,
Button,
createStyles,
Divider,
Flex,
Loader,
Popover,
ScrollArea,
Stack,
Text,
TextInput,
Title,
} from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { IconSearch, IconX } from '@tabler/icons';
import { useEffect, useState } from 'react';
import { ICON_PICKER_SLICE_LIMIT } from '../../../../../../../../data/constants';
import { useRepositoryIconsQuery } from '../../../../../../../tools/hooks/useRepositoryIconsQuery';
import { IconSelectorItem } from '../../../../../../../types/iconSelector/iconSelectorItem';
import { WalkxcodeRepositoryIcon } from '../../../../../../../types/iconSelector/repositories/walkxcodeIconRepository';
import { AppType } from '../../../../../../../types/app';
interface IconSelectorProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
onChange: (icon: IconSelectorItem) => void;
allowAppNamePropagation: boolean;
}
export const IconSelector = ({
onChange,
allowAppNamePropagation,
form,
}: IconSelectorProps) => {
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}`,
fileName: item.name,
}),
});
const [searchTerm, setSearchTerm] = useState<string>('');
const { classes } = useStyles();
const [debouncedValue] = useDebouncedValue(form.values.name, 500);
useEffect(() => {
if (allowAppNamePropagation !== true) {
return;
}
const matchingDebouncedIcon = data?.find(
(x) => replaceCharacters(x.fileName.split('.')[0]) === replaceCharacters(debouncedValue)
);
if (!matchingDebouncedIcon) {
return;
}
form.setFieldValue('appearance.iconUrl', matchingDebouncedIcon.url);
}, [debouncedValue]);
if (isLoading || !data) {
return <Loader />;
}
const replaceCharacters = (value: string) => value.toLowerCase().replaceAll('', '-');
const filteredItems = searchTerm
? data.filter((x) => replaceCharacters(x.url).includes(replaceCharacters(searchTerm)))
: data;
const slicedFilteredItems = filteredItems.slice(0, ICON_PICKER_SLICE_LIMIT);
const isTruncated =
slicedFilteredItems.length > 0 && slicedFilteredItems.length !== filteredItems.length;
return (
<Popover width={310}>
<Popover.Target>
<Button
className={classes.actionIcon}
variant="default"
leftIcon={<IconSearch size={20} />}
>
Icon Picker
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack pt={4}>
<TextInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.currentTarget.value)}
placeholder="Search for icons..."
variant="filled"
rightSection={
<ActionIcon onClick={() => setSearchTerm('')}>
<IconX opacity={0.5} size={20} strokeWidth={2} />
</ActionIcon>
}
/>
<ScrollArea style={{ height: 250 }} type="always">
<Flex gap={4} wrap="wrap" pr={15}>
{slicedFilteredItems.map((item) => (
<ActionIcon key={item.url} onClick={() => onChange(item)} size={40} p={3}>
<img className={classes.icon} src={item.url} alt="" />
</ActionIcon>
))}
</Flex>
{isTruncated && (
<Stack spacing="xs" pr={15}>
<Divider mt={35} mx="xl" />
<Title order={6} color="dimmed" align="center">
Search is limited to {ICON_PICKER_SLICE_LIMIT} icons
</Title>
<Text color="dimmed" align="center" size="sm">
To keep things snappy and fast, the search is limited to {ICON_PICKER_SLICE_LIMIT}{' '}
icons. Use the search box to find more icons.
</Text>
</Stack>
)}
</ScrollArea>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
const useStyles = createStyles(() => ({
flameIcon: {
margin: '0 auto',
},
icon: {
width: '100%',
height: '100%',
objectFit: 'contain',
},
actionIcon: {
alignSelf: 'end',
},
}));

View File

@@ -0,0 +1,31 @@
import { Tabs, TextInput, Switch, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconClick } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
interface BehaviourTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const BehaviourTab = ({ form }: BehaviourTabProps) => {
const { t } = useTranslation('');
return (
<Tabs.Panel value="behaviour" pt="xs">
<TextInput
icon={<IconClick size={16} />}
label="On click url"
description="Overrides the app URL when clicking on the app"
placeholder="URL that should be opened instead when clicking on the app"
variant="default"
mb="md"
{...form.getInputProps('behaviour.onClickUrl')}
/>
<Switch
label="Open in new tab"
{...form.getInputProps('behaviour.isOpeningNewTab', { type: 'checkbox' })}
/>
</Tabs.Panel>
);
};

View File

@@ -0,0 +1,54 @@
import { Tabs, Text, TextInput } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconCursorText, IconLink } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { EditAppModalTab } from '../type';
interface GeneralTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
openTab: (tab: EditAppModalTab) => void;
}
export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
const { t } = useTranslation('');
return (
<Tabs.Panel value="general" pt="lg">
<TextInput
icon={<IconCursorText size={16} />}
label="App name"
description="Used for displaying the app on the dashboard"
placeholder="My example app"
variant="default"
mb="md"
withAsterisk
{...form.getInputProps('name')}
/>
<TextInput
icon={<IconLink size={16} />}
label="App url"
description={
<Text>
URL that will be opened when clicking on the app. Can be overwritten using
<Text
onClick={() => openTab('behaviour')}
variant="link"
span
style={{
cursor: 'pointer',
}}
>
{' '}
on click URL{' '}
</Text>
when using external URLs to enhance security.
</Text>
}
placeholder="https://google.com"
variant="default"
withAsterisk
{...form.getInputProps('url')}
/>
</Tabs.Panel>
);
};

View File

@@ -0,0 +1,77 @@
import {
Button,
Card,
createStyles,
Flex,
Grid,
Group,
PasswordInput,
Stack,
ThemeIcon,
Title,
} from '@mantine/core';
import { TablerIcon } from '@tabler/icons';
import { useState } from 'react';
interface GenericSecretInputProps {
label: string;
value: string;
setIcon: TablerIcon;
onClickUpdateButton: (value: string | undefined) => void;
}
export const GenericSecretInput = ({
label,
value,
setIcon,
onClickUpdateButton,
...props
}: GenericSecretInputProps) => {
const { classes } = useStyles();
const Icon = setIcon;
const [displayUpdateField, setDisplayUpdateField] = useState<boolean>(false);
return (
<Card withBorder>
<Grid>
<Grid.Col className={classes.alignSelfCenter} xs={12} md={6}>
<Group spacing="sm">
<ThemeIcon color="green" variant="light">
<Icon size={18} />
</ThemeIcon>
<Stack spacing={0}>
<Title className={classes.subtitle} order={6}>
{label}
</Title>
</Stack>
</Group>
</Grid.Col>
<Grid.Col xs={12} md={6}>
<Flex gap={10} justify="end" align="end">
<Button variant="subtle" color="gray" px="xl">
Clear Secret
</Button>
{displayUpdateField === true ? (
<PasswordInput placeholder="new secret" width={200} {...props} />
) : (
<Button onClick={() => setDisplayUpdateField(true)} variant="light">
Set Secret
</Button>
)}
</Flex>
</Grid.Col>
</Grid>
</Card>
);
};
const useStyles = createStyles(() => ({
subtitle: {
lineHeight: 1.1,
},
alignSelfCenter: {
alignSelf: 'center',
},
}));

View File

@@ -0,0 +1,125 @@
/* eslint-disable @next/next/no-img-element */
import { Group, Select, SelectItem, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { forwardRef } from 'react';
import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
AppIntegrationPropertyType,
AppIntegrationType,
AppType,
} from '../../../../../../../../types/app';
interface IntegrationSelectorProps {
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
}
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
const { t } = useTranslation('');
// TODO: read this out from integrations dynamically.
const data: SelectItem[] = [
{
value: 'sabnzbd',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/sabnzbd.png',
label: 'SABnzbd',
},
{
value: 'deluge',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/deluge.png',
label: 'Deluge',
},
{
value: 'transmission',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/transmission.png',
label: 'Transmission',
},
{
value: 'qbittorrent',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/qbittorrent.png',
label: 'qBittorrent',
},
{
value: 'jellyseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/jellyseerr.png',
label: 'Jellyseerr',
},
{
value: 'overseerr',
image: 'https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/overseerr.png',
label: 'Overseerr',
},
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
if (!value) return [];
const integrationType = value as AppIntegrationType['type'];
if (integrationType === null) {
return [];
}
const requiredProperties = Object.entries(integrationFieldDefinitions).filter(([k, v]) => {
const val = integrationFieldProperties[integrationType['type']];
return val.includes(k as IntegrationField);
})!;
return requiredProperties.map(([k, value]) => ({
type: value.type,
field: k as IntegrationField,
value: undefined,
isDefined: false,
}));
};
const inputProps = form.getInputProps('integration.type');
return (
<Select
label="Integration configuration"
description="Treats this app as the selected integration and provides you with per-app configuration"
placeholder="Select your desired configuration"
itemComponent={SelectItemComponent}
data={data}
maxDropdownHeight={400}
clearable
variant="default"
mb="md"
icon={
form.values.integration?.type && (
<img
src={data.find((x) => x.value === form.values.integration?.type)?.image}
alt="test"
width={20}
height={20}
/>
)
}
onChange={(value) => {
form.setFieldValue('integration.properties', getNewProperties(value));
console.log(`changed to value ${value}`);
inputProps.onChange(value);
}}
{...inputProps}
/>
);
};
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
image: string;
label: string;
}
const SelectItemComponent = forwardRef<HTMLDivElement, ItemProps>(
({ image, label, ...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>
</div>
</Group>
</div>
)
);

View File

@@ -0,0 +1,81 @@
import { Stack } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconKey } from '@tabler/icons';
import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
AppIntegrationPropertyType,
AppType,
} from '../../../../../../../../types/app';
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
interface IntegrationOptionsRendererProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => {
const selectedIntegration = form.values.integration?.type;
if (!selectedIntegration) return null;
const displayedProperties = integrationFieldProperties[selectedIntegration];
return (
<Stack spacing="xs" mb="md">
{displayedProperties.map((property, index) => {
const [_, definition] = Object.entries(integrationFieldDefinitions).find(
([key]) => property === key
)!;
let indexInFormValue =
form.values.integration?.properties.findIndex((p) => p.field === property) ?? -1;
if (indexInFormValue === -1) {
const type = Object.entries(integrationFieldDefinitions).find(
([k, v]) => k === property
)![1].type;
const newProperty: AppIntegrationPropertyType = {
type,
field: property as IntegrationField,
isDefined: false,
};
form.insertListItem('integration.properties', newProperty);
indexInFormValue = form.values.integration!.properties.length;
}
const formValue = form.values.integration?.properties[indexInFormValue];
const isPresent = formValue?.isDefined;
if (!definition) {
return (
<GenericSecretInput
onClickUpdateButton={(value) => {
form.setFieldValue(`integration.properties.${index}.value`, value);
}}
key={`input-${property}`}
label={`${property} (potentionally unmapped)`}
secretIsPresent={isPresent}
setIcon={IconKey}
value={formValue.value}
{...form.getInputProps(`integration.properties.${index}.value`)}
/>
);
}
return (
<GenericSecretInput
onClickUpdateButton={(value) => {
form.setFieldValue(`integration.properties.${index}.value`, value);
}}
key={`input-${definition.label}`}
label={definition.label}
value=""
secretIsPresent={isPresent}
setIcon={definition.icon}
{...form.getInputProps(`integration.properties.${index}.value`)}
/>
);
})}
</Stack>
);
};

View File

@@ -0,0 +1,41 @@
import { Alert, Divider, Tabs, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconAlertTriangle } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { AppType } from '../../../../../../types/app';
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
interface IntegrationTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const IntegrationTab = ({ form }: IntegrationTabProps) => {
const { t } = useTranslation('');
const hasIntegrationSelected = form.values.integration?.type;
return (
<Tabs.Panel value="integration" pt="lg">
<IntegrationSelector form={form} />
{hasIntegrationSelected && (
<>
<Divider label="Integration Configuration" labelPosition="center" mt="xl" mb="md" />
<Text size="sm" color="dimmed" mb="lg">
To update a secret, enter a value and click the save button. To remove a secret, use the
clear button.
</Text>
<IntegrationOptionsRenderer form={form} />
<Alert icon={<IconAlertTriangle />} color="yellow">
<Text>
Please note that Homarr removes secrets from the configuration for security reasons.
Thus, you can only either define or unset any credentials. Your credentials act as the
main access for your integrations and you should <b>never</b> share them with anybody
else. Make sure to <b>store and manage your secrets safely</b>.
</Text>
</Alert>
</>
)}
</Tabs.Panel>
);
};

View File

@@ -0,0 +1,37 @@
import { Tabs, Switch, MultiSelect } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { StatusCodes } from '../../../../../../tools/acceptableStatusCodes';
import { AppType } from '../../../../../../types/app';
interface NetworkTabProps {
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const NetworkTab = ({ form }: NetworkTabProps) => {
const { t } = useTranslation('');
return (
<Tabs.Panel value="network" pt="lg">
<Switch
label="Enable status checker"
description="Sends a simple HTTP / HTTPS request to check if your app is online"
mb="md"
defaultChecked={form.values.network.enabledStatusChecker}
{...form.getInputProps('network.enabledStatusChecker')}
/>
{form.values.network.enabledStatusChecker && (
<MultiSelect
required
label="HTTP status codes"
description="Determines what response codes are allowed for this app to be 'Online'"
data={StatusCodes}
clearable
searchable
defaultValue={form.values.network.okStatus}
variant="default"
{...form.getInputProps('network.statusCodes')}
/>
)}
</Tabs.Panel>
);
};

View File

@@ -0,0 +1,59 @@
// disabled due to too many dynamic targets for next image cache
/* eslint-disable @next/next/no-img-element */
import Image from 'next/image';
import { createStyles, Loader } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { AppType } from '../../../../../../types/app';
interface DebouncedAppIconProps {
width: number;
height: number;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
debouncedWaitPeriod?: number;
}
export const DebouncedAppIcon = ({
form,
width,
height,
debouncedWaitPeriod = 1000,
}: DebouncedAppIconProps) => {
const { classes } = useStyles();
const [debouncedIconImageUrl] = useDebouncedValue(
form.values.appearance.iconUrl,
debouncedWaitPeriod
);
if (debouncedIconImageUrl !== form.values.appearance.iconUrl) {
return <Loader width={width} height={height} />;
}
if (debouncedIconImageUrl.length > 0) {
return (
<img
className={classes.iconImage}
src={debouncedIconImageUrl}
width={width}
height={height}
alt=""
/>
);
}
return (
<Image
className={classes.iconImage}
src="/imgs/logo/logo.png"
width={width}
height={height}
alt=""
/>
);
};
const useStyles = createStyles(() => ({
iconImage: {
objectFit: 'contain',
},
}));

View File

@@ -0,0 +1,6 @@
export type EditAppModalTab =
| 'general'
| 'behaviour'
| 'network'
| 'appereance'
| 'integration';