🎨 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

@@ -1,20 +1,196 @@
{
"name": "default",
"services": [
"schemaVersion": "1.0",
"configProperties": {
"name": "default"
},
"categories": [],
"wrappers": [
{
"name": "example",
"id": "09c45847-8afc-4c1a-9697-f03192de948a",
"type": "Other",
"icon": "https://c.tenor.com/o656qFKDzeUAAAAC/rick-astley-never-gonna-give-you-up.gif",
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e",
"position": 1
}
],
"apps": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33a",
"name": "Documentation",
"url": "https://homarr.dev",
"behaviour": {
"onClickUrl": "https://homarr.dev",
"isOpeningInNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
}
},
"shape": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
"name": "Support",
"url": "https://github.com/ajnart/homarr",
"behaviour": {
"onClickUrl": "https://github.com/ajnart/homarr",
"isOpeningInNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/github.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
}
},
"shape": {
"location": {
"x": 3,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Community",
"url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": {
"onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningInNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxhub/dashboard-icons/png/discord.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
}
},
"shape": {
"location": {
"x": 6,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://homarr.dev/docs/community/donate",
"behaviour": {
"onClickUrl": "https://homarr.dev/docs/community/donate",
"isOpeningInNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "wrapper",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33e"
}
},
"shape": {
"location": {
"x": 9,
"y": 0
},
"size": {
"width": 3,
"height": 3
}
}
}
],
"integrations": [],
"settings": {
"searchUrl": "https://google.com/search?q="
},
"modules": {
"Search Bar": {
"enabled": true
"common": {
"searchEngine": {
"type": "google",
"properties": {}
}
},
"customization": {
"layout": {
"enabledLeftSidebar": false,
"enabledRightSidebar": false,
"enabledDocker": false,
"enabledPing": false,
"enabledSearchbar": true
},
"pageTitle": "Homarr",
"logoImageUrl": "/imgs/logo/logo.png",
"faviconUrl": "/imgs/logo/logo.png",
"backgroundImageUrl": "",
"customCss": "",
"colors": {
"primary": "red",
"secondary": "orange",
"shade": 5
},
"appOpacity": 100
}
}
}

View File

@@ -50,7 +50,7 @@ export const AboutModal = ({ opened, closeModal }: AboutModalProps) => {
>
<Text mb="lg">
Homarr is a simple and modern homepage for your server that helps you access all of your
services in one place. It integrates with the services you use to display useful information
apps in one place. It integrates with the apps you use to display useful information
or control them. It&apos;s easy to install and supports many different devices.
</Text>

View File

@@ -23,7 +23,7 @@ import { UsenetModule, TorrentsModule } from '../../modules';
const AppShelf = (props: any) => {
const { config, setConfig } = useConfig();
// Extract all the categories from the services in config
const categoryList = config.services.reduce((acc, cur) => {
const categoryList = config.apps.reduce((acc, cur) => {
if (cur.category && !acc.includes(cur.category)) {
acc.push(cur.category);
}
@@ -67,9 +67,9 @@ const AppShelf = (props: any) => {
if (active.id !== over.id) {
const newConfig = { ...config };
const activeIndex = newConfig.services.findIndex((e) => e.id === active.id);
const overIndex = newConfig.services.findIndex((e) => e.id === over.id);
newConfig.services = arrayMove(newConfig.services, activeIndex, overIndex);
const activeIndex = newConfig.apps.findIndex((e) => e.id === active.id);
const overIndex = newConfig.apps.findIndex((e) => e.id === over.id);
newConfig.apps = arrayMove(newConfig.apps, activeIndex, overIndex);
setConfig(newConfig);
}
@@ -78,14 +78,14 @@ const AppShelf = (props: any) => {
const getItems = (filter?: string) => {
// If filter is not set, return all the services without a category or a null category
let filtered = config.services;
let filtered = config.apps;
const modules = Object.values(Modules).map((module) => module);
if (!filter) {
filtered = config.services.filter((e) => !e.category || e.category === null);
filtered = config.apps.filter((e) => !e.category || e.category === null);
}
if (filter) {
filtered = config.services.filter((e) => e.category === filter);
filtered = config.apps.filter((e) => e.category === filter);
}
return (
<DndContext
@@ -94,7 +94,7 @@ const AppShelf = (props: any) => {
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={config.services}>
<SortableContext items={config.apps}>
<Grid gutter="lg" grow={config.settings.grow}>
{filtered.map((service) => (
<Grid.Col key={service.id} span="content">
@@ -112,7 +112,7 @@ const AppShelf = (props: any) => {
}}
>
{activeId ? (
<AppShelfItem service={config.services.find((e) => e.id === activeId)} id={activeId} />
<AppShelfItem service={config.apps.find((e) => e.id === activeId)} id={activeId} />
) : null}
</DragOverlay>
</DndContext>

View File

@@ -1,18 +0,0 @@
import { Avatar, Group, Text } from '@mantine/core';
interface smallServiceItem {
label: string;
icon?: string;
url?: string;
}
export default function SmallServiceItem(props: any) {
const { service }: { service: smallServiceItem } = props;
// TODO : Use Next/link
return (
<Group>
{service.icon && <Avatar src={service.icon} />}
<Text>{service.label}</Text>
</Group>
);
}

View File

@@ -2,18 +2,18 @@ import { SelectItem } from '@mantine/core';
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
import { ChangePositionModal } from './ChangePositionModal';
type ChangeServicePositionModalInnerProps = {
service: ServiceType;
type ChangeAppPositionModalInnerProps = {
app: AppType;
};
export const ChangeServicePositionModal = ({
export const ChangeAppPositionModal = ({
id,
context,
innerProps,
}: ContextModalProps<ChangeServicePositionModalInnerProps>) => {
}: ContextModalProps<ChangeAppPositionModalInnerProps>) => {
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
@@ -24,9 +24,9 @@ export const ChangeServicePositionModal = ({
updateConfig(configName, (previousConfig) => ({
...previousConfig,
services: [
...previousConfig.services.filter((x) => x.id !== innerProps.service.id),
{ ...innerProps.service, shape: { location: { x, y }, size: { width, height } } },
apps: [
...previousConfig.apps.filter((x) => x.id !== innerProps.app.id),
{ ...innerProps.app, shape: { location: { x, y }, size: { width, height } } },
],
}));
context.closeModal(id);
@@ -45,10 +45,10 @@ export const ChangeServicePositionModal = ({
onCancel={handleCancel}
widthData={widthData}
heightData={heightData}
initialX={innerProps.service.shape.location.x}
initialY={innerProps.service.shape.location.y}
initialWidth={innerProps.service.shape.size.width}
initialHeight={innerProps.service.shape.size.height}
initialX={innerProps.app.shape.location.x}
initialY={innerProps.app.shape.location.y}
initialWidth={innerProps.app.shape.size.width}
initialHeight={innerProps.app.shape.size.height}
/>
);
};

View File

@@ -13,32 +13,32 @@ import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
import { AppearanceTab } from './Tabs/AppereanceTab/AppereanceTab';
import { BehaviourTab } from './Tabs/BehaviourTab/BehaviourTab';
import { GeneralTab } from './Tabs/GeneralTab/GeneralTab';
import { IntegrationTab } from './Tabs/IntegrationTab/IntegrationTab';
import { NetworkTab } from './Tabs/NetworkTab/NetworkTab';
import { DebouncedServiceIcon } from './Tabs/Shared/DebouncedServiceIcon';
import { EditServiceModalTab } from './Tabs/type';
import { DebouncedAppIcon } from './Tabs/Shared/DebouncedAppIcon';
import { EditAppModalTab } from './Tabs/type';
const serviceUrlRegex =
const appUrlRegex =
'(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^\\s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^\\s]{2,}|www.[a-zA-Z0-9]+.[^\\s]{2,})';
export const EditServiceModal = ({
export const EditAppModal = ({
context,
id,
innerProps,
}: ContextModalProps<{ service: ServiceType; allowServiceNamePropagation: boolean }>) => {
}: ContextModalProps<{ app: AppType; allowAppNamePropagation: boolean }>) => {
const { t } = useTranslation();
const { name: configName, config } = useConfigContext();
const updateConfig = useConfigStore((store) => store.updateConfig);
const [allowServiceNamePropagation, setAllowServiceNamePropagation] = useState<boolean>(
innerProps.allowServiceNamePropagation
const [allowAppNamePropagation, setAllowAppNamePropagation] = useState<boolean>(
innerProps.allowAppNamePropagation
);
const form = useForm<ServiceType>({
initialValues: innerProps.service,
const form = useForm<AppType>({
initialValues: innerProps.app,
validate: {
name: (name) => (!name ? 'Name is required' : null),
url: (url) => {
@@ -46,7 +46,7 @@ export const EditServiceModal = ({
return 'Url is required';
}
if (!url.match(serviceUrlRegex)) {
if (!url.match(appUrlRegex)) {
return 'Value is not a valid url';
}
@@ -67,7 +67,7 @@ export const EditServiceModal = ({
return null;
}
if (!url.match(serviceUrlRegex)) {
if (!url.match(appUrlRegex)) {
return 'Uri override is not a valid uri';
}
@@ -78,21 +78,21 @@ export const EditServiceModal = ({
validateInputOnChange: true,
});
const onSubmit = (values: ServiceType) => {
const onSubmit = (values: AppType) => {
if (!configName) {
return;
}
updateConfig(configName, (previousConfig) => ({
...previousConfig,
services: [...previousConfig.services.filter((x) => x.id !== form.values.id), form.values],
apps: [...previousConfig.apps.filter((x) => x.id !== form.values.id), form.values],
}));
// also close the parent modal
context.closeAll();
};
const [activeTab, setActiveTab] = useState<EditServiceModalTab>('general');
const [activeTab, setActiveTab] = useState<EditAppModalTab>('general');
const closeModal = () => {
context.closeModal(id);
@@ -125,17 +125,17 @@ export const EditServiceModal = ({
</Alert>
))}
<Stack spacing={0} align="center" my="lg">
<DebouncedServiceIcon form={form} width={120} height={120} />
<DebouncedAppIcon form={form} width={120} height={120} />
<Text align="center" weight="bold" size="lg" mt="md">
{form.values.name ?? 'New Service'}
{form.values.name ?? 'New App'}
</Text>
</Stack>
<form onSubmit={form.onSubmit(onSubmit)}>
<Tabs
value={activeTab}
onTabChange={(tab) => setActiveTab(tab as EditServiceModalTab)}
onTabChange={(tab) => setActiveTab(tab as EditAppModalTab)}
defaultValue="general"
>
<Tabs.List grow>
@@ -181,8 +181,8 @@ export const EditServiceModal = ({
<NetworkTab form={form} />
<AppearanceTab
form={form}
disallowServiceNameProgagation={() => setAllowServiceNamePropagation(false)}
allowServiceNamePropagation={allowServiceNamePropagation}
disallowAppNameProgagation={() => setAllowAppNamePropagation(false)}
allowAppNamePropagation={allowAppNamePropagation}
/>
<IntegrationTab form={form} />
</Tabs>
@@ -199,9 +199,3 @@ export const EditServiceModal = ({
</>
);
};
const useStyles = createStyles(() => ({
serviceImage: {
objectFit: 'contain',
},
}));

View File

@@ -1,20 +1,20 @@
import { createStyles, Flex, Tabs, TextInput } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { ServiceType } from '../../../../../../types/service';
import { DebouncedServiceIcon } from '../Shared/DebouncedServiceIcon';
import { AppType } from '../../../../../../types/app';
import { DebouncedAppIcon } from '../Shared/DebouncedAppIcon';
import { IconSelector } from './IconSelector/IconSelector';
interface AppearanceTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
disallowServiceNameProgagation: () => void;
allowServiceNamePropagation: boolean;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
disallowAppNameProgagation: () => void;
allowAppNamePropagation: boolean;
}
export const AppearanceTab = ({
form,
disallowServiceNameProgagation,
allowServiceNamePropagation,
disallowAppNameProgagation,
allowAppNamePropagation,
}: AppearanceTabProps) => {
const { t } = useTranslation('');
const { classes } = useStyles();
@@ -25,9 +25,9 @@ export const AppearanceTab = ({
<TextInput
defaultValue={form.values.appearance.iconUrl}
className={classes.textInput}
icon={<DebouncedServiceIcon form={form} width={20} height={20} />}
label="Service Icon"
description="Logo of your service displayed in your dashboard. Must return a body content containg an image"
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
@@ -40,9 +40,9 @@ export const AppearanceTab = ({
iconUrl: item.url,
},
});
disallowServiceNameProgagation();
disallowAppNameProgagation();
}}
allowServiceNamePropagation={allowServiceNamePropagation}
allowAppNamePropagation={allowAppNamePropagation}
form={form}
/>
</Flex>

View File

@@ -21,17 +21,17 @@ 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 { ServiceType } from '../../../../../../../types/service';
import { AppType } from '../../../../../../../types/app';
interface IconSelectorProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
onChange: (icon: IconSelectorItem) => void;
allowServiceNamePropagation: boolean;
allowAppNamePropagation: boolean;
}
export const IconSelector = ({
onChange,
allowServiceNamePropagation,
allowAppNamePropagation,
form,
}: IconSelectorProps) => {
const { data, isLoading } = useRepositoryIconsQuery<WalkxcodeRepositoryIcon>({
@@ -48,7 +48,7 @@ export const IconSelector = ({
const [debouncedValue] = useDebouncedValue(form.values.name, 500);
useEffect(() => {
if (allowServiceNamePropagation !== true) {
if (allowAppNamePropagation !== true) {
return;
}

View File

@@ -2,10 +2,10 @@ import { Tabs, TextInput, Switch, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconClick } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ServiceType } from '../../../../../../types/service';
import { AppType } from '../../../../../../types/app';
interface BehaviourTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const BehaviourTab = ({ form }: BehaviourTabProps) => {
@@ -15,8 +15,8 @@ export const BehaviourTab = ({ form }: BehaviourTabProps) => {
<TextInput
icon={<IconClick size={16} />}
label="On click url"
description="Overrides the service URL when clicking on the service"
placeholder="URL that should be opened instead when clicking on the service"
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')}

View File

@@ -2,12 +2,12 @@ import { Tabs, Text, TextInput } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconCursorText, IconLink } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ServiceType } from '../../../../../../types/service';
import { EditServiceModalTab } from '../type';
import { AppType } from '../../../../../../types/app';
import { EditAppModalTab } from '../type';
interface GeneralTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
openTab: (tab: EditServiceModalTab) => void;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
openTab: (tab: EditAppModalTab) => void;
}
export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
@@ -16,9 +16,9 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
<Tabs.Panel value="general" pt="lg">
<TextInput
icon={<IconCursorText size={16} />}
label="Service name"
description="Used for displaying the service on the dashboard"
placeholder="My example service"
label="App name"
description="Used for displaying the app on the dashboard"
placeholder="My example app"
variant="default"
mb="md"
withAsterisk
@@ -26,10 +26,10 @@ export const GeneralTab = ({ form, openTab }: GeneralTabProps) => {
/>
<TextInput
icon={<IconLink size={16} />}
label="Service url"
label="App url"
description={
<Text>
URL that will be opened when clicking on the service. Can be overwritten using
URL that will be opened when clicking on the app. Can be overwritten using
<Text
onClick={() => openTab('behaviour')}
variant="link"

View File

@@ -7,13 +7,13 @@ import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
ServiceIntegrationPropertyType,
ServiceIntegrationType,
ServiceType,
} from '../../../../../../../../types/service';
AppIntegrationPropertyType,
AppIntegrationType,
AppType,
} from '../../../../../../../../types/app';
interface IntegrationSelectorProps {
form: UseFormReturnType<ServiceType, (item: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (item: AppType) => AppType>;
}
export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
@@ -53,9 +53,9 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
},
].filter((x) => Object.keys(integrationFieldProperties).includes(x.value));
const getNewProperties = (value: string | null): ServiceIntegrationPropertyType[] => {
const getNewProperties = (value: string | null): AppIntegrationPropertyType[] => {
if (!value) return [];
const integrationType = value as ServiceIntegrationType['type'];
const integrationType = value as AppIntegrationType['type'];
if (integrationType === null) {
return [];
}
@@ -77,7 +77,7 @@ export const IntegrationSelector = ({ form }: IntegrationSelectorProps) => {
return (
<Select
label="Integration configuration"
description="Treats this service as the selected integration and provides you with per-service 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}

View File

@@ -5,13 +5,13 @@ import {
IntegrationField,
integrationFieldDefinitions,
integrationFieldProperties,
ServiceIntegrationPropertyType,
ServiceType,
} from '../../../../../../../../types/service';
AppIntegrationPropertyType,
AppType,
} from '../../../../../../../../types/app';
import { GenericSecretInput } from '../InputElements/GenericSecretInput';
interface IntegrationOptionsRendererProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererProps) => {
@@ -34,7 +34,7 @@ export const IntegrationOptionsRenderer = ({ form }: IntegrationOptionsRendererP
const type = Object.entries(integrationFieldDefinitions).find(
([k, v]) => k === property
)![1].type;
const newProperty: ServiceIntegrationPropertyType = {
const newProperty: AppIntegrationPropertyType = {
type,
field: property as IntegrationField,
isDefined: false,

View File

@@ -2,12 +2,12 @@ import { Alert, Divider, Tabs, Text } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { IconAlertTriangle } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { ServiceType } from '../../../../../../types/service';
import { AppType } from '../../../../../../types/app';
import { IntegrationSelector } from './Components/InputElements/IntegrationSelector';
import { IntegrationOptionsRenderer } from './Components/IntegrationOptionsRenderer/IntegrationOptionsRenderer';
interface IntegrationTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const IntegrationTab = ({ form }: IntegrationTabProps) => {

View File

@@ -2,10 +2,10 @@ import { Tabs, Switch, MultiSelect } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useTranslation } from 'next-i18next';
import { StatusCodes } from '../../../../../../tools/acceptableStatusCodes';
import { ServiceType } from '../../../../../../types/service';
import { AppType } from '../../../../../../types/app';
interface NetworkTabProps {
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
}
export const NetworkTab = ({ form }: NetworkTabProps) => {
@@ -14,7 +14,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
<Tabs.Panel value="network" pt="lg">
<Switch
label="Enable status checker"
description="Sends a simple HTTP / HTTPS request to check if your service is online"
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')}
@@ -23,7 +23,7 @@ export const NetworkTab = ({ form }: NetworkTabProps) => {
<MultiSelect
required
label="HTTP status codes"
description="Determines what response codes are allowed for this service to be 'Online'"
description="Determines what response codes are allowed for this app to be 'Online'"
data={StatusCodes}
clearable
searchable

View File

@@ -4,21 +4,21 @@ import Image from 'next/image';
import { createStyles, Loader } from '@mantine/core';
import { UseFormReturnType } from '@mantine/form';
import { useDebouncedValue } from '@mantine/hooks';
import { ServiceType } from '../../../../../../types/service';
import { AppType } from '../../../../../../types/app';
interface DebouncedServiceIconProps {
interface DebouncedAppIconProps {
width: number;
height: number;
form: UseFormReturnType<ServiceType, (values: ServiceType) => ServiceType>;
form: UseFormReturnType<AppType, (values: AppType) => AppType>;
debouncedWaitPeriod?: number;
}
export const DebouncedServiceIcon = ({
export const DebouncedAppIcon = ({
form,
width,
height,
debouncedWaitPeriod = 1000,
}: DebouncedServiceIconProps) => {
}: DebouncedAppIconProps) => {
const { classes } = useStyles();
const [debouncedIconImageUrl] = useDebouncedValue(
form.values.appearance.iconUrl,

View File

@@ -1,4 +1,4 @@
export type EditServiceModalTab =
export type EditAppModalTab =
| 'general'
| 'behaviour'
| 'network'

View File

@@ -23,7 +23,7 @@ export const AvailableIntegrationElements = ({
<SelectorBackArrow onClickBack={onClickBack} />
<Text mb="md" color="dimmed">
Integrations interact with your services, to provide you with more control over your
Integrations interact with your apps, to provide you with more control over your
applications. They usually require a few configurations before use.
</Text>

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { openContextModalGeneric } from '../../../../../../tools/mantineModalManagerExtensions';
import { ServiceType } from '../../../../../../types/service';
import { AppType } from '../../../../../../types/app';
import { useStyles } from '../Shared/styles';
interface AvailableElementTypesProps {
@@ -24,16 +24,16 @@ export const AvailableElementTypes = ({
<Space h="lg" />
<Group spacing="md" grow>
<ElementItem
name="Service"
name="Apps"
icon={<IconBox size={40} strokeWidth={1.3} />}
onClick={() => {
openContextModalGeneric<{ service: ServiceType; allowServiceNamePropagation: boolean }>(
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>(
{
modal: 'editService',
modal: 'editApp',
innerProps: {
service: {
app: {
id: uuidv4(),
name: 'Your service',
name: 'Your app',
url: 'https://homarr.dev',
appearance: {
iconUrl: '/imgs/logo/logo.png',
@@ -67,7 +67,7 @@ export const AvailableElementTypes = ({
properties: [],
},
},
allowServiceNamePropagation: true,
allowAppNamePropagation: true,
},
size: 'xl',
}

View File

@@ -16,7 +16,7 @@ export const AvailableStaticTypes = ({ onClickBack }: AvailableStaticTypesProps)
<Text mb="md" color="dimmed">
Static elements provide you additional control over your dashboard. They are static, because
they don&apos;t integrate with any services and their content never changes.
they don&apos;t integrate with any apps and their content never changes.
</Text>
<Grid>

View File

@@ -3,4 +3,4 @@ interface ServiceIconProps {
service: string;
}
export const ServiceIcon = ({ size }: ServiceIconProps) => null;
export const AppIcon = ({ size }: ServiceIconProps) => null;

View File

@@ -1,19 +1,19 @@
import { openContextModalGeneric } from '../../../../tools/mantineModalManagerExtensions';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
import { GenericTileMenu } from '../GenericTileMenu';
interface TileMenuProps {
service: ServiceType;
app: AppType;
}
export const ServiceMenu = ({ service }: TileMenuProps) => {
export const AppMenu = ({ app }: TileMenuProps) => {
const handleClickEdit = () => {
openContextModalGeneric<{ service: ServiceType; allowServiceNamePropagation: boolean }>({
modal: 'editService',
openContextModalGeneric<{ app: AppType; allowAppNamePropagation: boolean }>({
modal: 'editApp',
size: 'xl',
innerProps: {
service,
allowServiceNamePropagation: false,
app,
allowAppNamePropagation: false,
},
styles: {
root: {
@@ -25,9 +25,9 @@ export const ServiceMenu = ({ service }: TileMenuProps) => {
const handleClickChangePosition = () => {
openContextModalGeneric({
modal: 'changeServicePositionModal',
modal: 'changeAppPositionModal',
innerProps: {
service,
app,
},
styles: {
root: {

View File

@@ -3,23 +3,23 @@ import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../config/provider';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
interface ServicePingProps {
service: ServiceType;
interface AppPingProps {
app: AppType;
}
export const ServicePing = ({ service }: ServicePingProps) => {
export const AppPing = ({ app }: AppPingProps) => {
const { t } = useTranslation('modules/ping');
const { config } = useConfigContext();
const active =
(config?.settings.customization.layout.enabledPing && service.network.enabledStatusChecker) ??
(config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ??
false;
const { data, isLoading } = useQuery({
queryKey: [`ping/${service.id}`],
queryKey: [`ping/${app.id}`],
queryFn: async () => {
const response = await fetch(`/api/modules/ping?url=${encodeURI(service.url)}`);
const isOk = service.network.okStatus.includes(response.status);
const response = await fetch(`/api/modules/ping?url=${encodeURI(app.url)}`);
const isOk = app.network.okStatus.includes(response.status);
return {
status: response.status,
state: isOk ? 'online' : 'down',

View File

@@ -1,19 +1,19 @@
import { Card, Center, Text, UnstyledButton } from '@mantine/core';
import { NextLink } from '@mantine/next';
import { createStyles } from '@mantine/styles';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
import { useCardStyles } from '../../../layout/useCardStyles';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { HomarrCardWrapper } from '../HomarrCardWrapper';
import { BaseTileProps } from '../type';
import { ServiceMenu } from './ServiceMenu';
import { ServicePing } from './ServicePing';
import { AppMenu } from './AppMenu';
import { AppPing } from './AppPing';
interface ServiceTileProps extends BaseTileProps {
service: ServiceType;
interface AppTileProps extends BaseTileProps {
app: AppType;
}
export const ServiceTile = ({ className, service }: ServiceTileProps) => {
export const AppTile = ({ className, app }: AppTileProps) => {
const isEditMode = useEditModeStore((x) => x.enabled);
const { cx, classes } = useStyles();
@@ -24,25 +24,25 @@ export const ServiceTile = ({ className, service }: ServiceTileProps) => {
const inner = (
<>
<Text align="center" weight={500} size="md" className={classes.serviceName}>
{service.name}
<Text align="center" weight={500} size="md" className={classes.appName}>
{app.name}
</Text>
<Center style={{ height: '75%', flex: 1 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className={classes.image} src={service.appearance.iconUrl} alt="" />
<img className={classes.image} src={app.appearance.iconUrl} alt="" />
</Center>
</>
);
return (
<HomarrCardWrapper className={className}>
{/* TODO: add service menu */}
{/* TODO: add app menu */}
<div style={{ position: 'absolute', top: 10, right: 10 }}>
<ServiceMenu service={service} />
<AppMenu app={app} />
</div>
{!service.url || isEditMode ? (
{!app.url || isEditMode ? (
<UnstyledButton
className={classes.button}
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
@@ -53,14 +53,14 @@ export const ServiceTile = ({ className, service }: ServiceTileProps) => {
<UnstyledButton
style={{ pointerEvents: isEditMode ? 'none' : 'auto' }}
component={NextLink}
href={service.url}
target={service.behaviour.isOpeningNewTab ? '_blank' : '_self'}
href={app.url}
target={app.behaviour.isOpeningNewTab ? '_blank' : '_self'}
className={cx(classes.button, classes.link)}
>
{inner}
</UnstyledButton>
)}
<ServicePing service={service} />
<AppPing app={app} />
</HomarrCardWrapper>
);
};
@@ -72,8 +72,8 @@ const useStyles = createStyles((theme, _params, getRef) => ({
maxWidth: '80%',
transition: 'transform 100ms ease-in-out',
},
serviceName: {
ref: getRef('serviceName'),
appName: {
ref: getRef('appName'),
},
button: {
height: '100%',
@@ -87,8 +87,8 @@ const useStyles = createStyles((theme, _params, getRef) => ({
[`&:hover .${getRef('image')}`]: {
// TODO: add styles for image when hovering card
},
[`&:hover .${getRef('serviceName')}`]: {
// TODO: add styles for service name when hovering card
[`&:hover .${getRef('appName')}`]: {
// TODO: add styles for app name when hovering card
},
},
}));

View File

@@ -2,7 +2,7 @@ import { ReactNode, RefObject } from 'react';
interface GridstackTileWrapperProps {
id: string;
type: 'service' | 'module';
type: 'app' | 'module';
x?: number;
y?: number;
width?: number;

View File

@@ -5,11 +5,11 @@ import { DashDotTile } from '../../../widgets/dashDot/DashDotTile';
import { UseNetTile } from '../../../widgets/useNet/UseNetTile';
import { WeatherTile } from '../../../widgets/weather/WeatherTile';
import { EmptyTile } from './EmptyTile';
import { ServiceTile } from './Service/ServiceTile';
import { AppTile } from './Apps/AppTile';
// TODO: just remove and use service (later app) directly. For widgets the the definition should contain min/max width/height
// TODO: just remove and use app (later app) directly. For widgets the the definition should contain min/max width/height
type TileDefinitionProps = {
[key in keyof IntegrationsType | 'service']: {
[key in keyof IntegrationsType | 'app']: {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
@@ -20,8 +20,8 @@ type TileDefinitionProps = {
// TODO: change components for other modules
export const Tiles: TileDefinitionProps = {
service: {
component: ServiceTile,
app: {
component: AppTile,
minWidth: 2,
maxWidth: 12,
minHeight: 2,

View File

@@ -27,19 +27,19 @@ export const DashboardCategory = ({ category }: DashboardCategoryProps) => {
data-category={category.id}
ref={refs.wrapper}
>
{items?.map((service) => {
const { component: TileComponent, ...tile } = Tiles['service'];
{items?.map((app) => {
const { component: TileComponent, ...tile } = Tiles['app'];
return (
<GridstackTileWrapper
id={service.id}
type="service"
key={service.id}
itemRef={refs.items.current[service.id]}
id={app.id}
type="app"
key={app.id}
itemRef={refs.items.current[app.id]}
{...tile}
{...service.shape.location}
{...service.shape.size}
{...app.shape.location}
{...app.shape.size}
>
<TileComponent className="grid-stack-item-content" service={service} />
<TileComponent className="grid-stack-item-content" app={app} />
</GridstackTileWrapper>
);
})}

View File

@@ -29,19 +29,19 @@ export const DashboardSidebar = ({ location }: DashboardSidebarProps) => {
gs-min-row={minRow}
ref={refs.wrapper}
>
{items.map((service) => {
const { component: TileComponent, ...tile } = Tiles['service'];
{items.map((app) => {
const { component: TileComponent, ...tile } = Tiles['app'];
return (
<GridstackTileWrapper
id={service.id}
type="service"
key={service.id}
itemRef={refs.items.current[service.id]}
id={app.id}
type="app"
key={app.id}
itemRef={refs.items.current[app.id]}
{...tile}
{...service.shape.location}
{...service.shape.size}
{...app.shape.location}
{...app.shape.size}
>
<TileComponent className="grid-stack-item-content" service={service} />
<TileComponent className="grid-stack-item-content" app={app} />
</GridstackTileWrapper>
);
})}

View File

@@ -17,19 +17,19 @@ export const DashboardWrapper = ({ wrapper }: DashboardWrapperProps) => {
data-wrapper={wrapper.id}
ref={refs.wrapper}
>
{items?.map((service) => {
const { component: TileComponent, ...tile } = Tiles['service'];
{items?.map((app) => {
const { component: TileComponent, ...tile } = Tiles['app'];
return (
<GridstackTileWrapper
id={service.id}
type="service"
key={service.id}
itemRef={refs.items.current[service.id]}
id={app.id}
type="app"
key={app.id}
itemRef={refs.items.current[app.id]}
{...tile}
{...service.shape.location}
{...service.shape.size}
{...app.shape.location}
{...app.shape.size}
>
<TileComponent className="grid-stack-item-content" service={service} />
<TileComponent className="grid-stack-item-content" app={app} />
</GridstackTileWrapper>
);
})}

View File

@@ -1,7 +1,7 @@
import { GridStack, GridStackNode } from 'fily-publish-gridstack';
import { MutableRefObject, RefObject } from 'react';
import { IntegrationsType } from '../../../../types/integration';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
export const initializeGridstack = (
areaType: 'wrapper' | 'category' | 'sidebar',
@@ -9,7 +9,7 @@ export const initializeGridstack = (
gridRef: MutableRefObject<GridStack | undefined>,
itemRefs: MutableRefObject<Record<string, RefObject<HTMLDivElement>>>,
areaId: string,
items: ServiceType[],
items: AppType[],
integrations: IntegrationsType,
isEditMode: boolean,
events: {

View File

@@ -12,13 +12,13 @@ import { useConfigContext } from '../../../../config/provider';
import { useConfigStore } from '../../../../config/store';
import { useResize } from '../../../../hooks/use-resize';
import { IntegrationsType } from '../../../../types/integration';
import { ServiceType } from '../../../../types/service';
import { AppType } from '../../../../types/app';
import { TileBaseType } from '../../../../types/tile';
import { useEditModeStore } from '../../Views/useEditModeStore';
import { initializeGridstack } from './init-gridstack';
interface UseGristackReturnType {
items: ServiceType[];
items: AppType[];
integrations: Partial<IntegrationsType>;
refs: {
wrapper: RefObject<HTMLDivElement>;
@@ -45,7 +45,7 @@ export const useGridstack = (
const items = useMemo(
() =>
config?.services.filter(
config?.apps.filter(
(x) =>
x.area.type === areaType &&
(x.area.type === 'sidebar'
@@ -100,8 +100,8 @@ export const useGridstack = (
// Updates the config and defines the new position of the item
updateConfig(configName, (previous) => {
const currentItem =
itemType === 'service'
? previous.services.find((x) => x.id === itemId)
itemType === 'app'
? previous.apps.find((x) => x.id === itemId)
: previous.integrations[itemId as keyof typeof previous.integrations];
if (!currentItem) return previous;
@@ -116,12 +116,12 @@ export const useGridstack = (
},
};
if (itemType === 'service') {
if (itemType === 'app') {
return {
...previous,
services: [
...previous.services.filter((x) => x.id !== itemId),
{ ...(currentItem as ServiceType) },
apps: [
...previous.apps.filter((x) => x.id !== itemId),
{ ...(currentItem as AppType) },
],
};
}
@@ -147,8 +147,8 @@ export const useGridstack = (
// Updates the config and defines the new position and wrapper of the item
updateConfig(configName, (previous) => {
const currentItem =
itemType === 'service'
? previous.services.find((x) => x.id === itemId)
itemType === 'app'
? previous.apps.find((x) => x.id === itemId)
: previous.integrations[itemId as keyof typeof previous.integrations];
if (!currentItem) return previous;
@@ -180,12 +180,12 @@ export const useGridstack = (
},
};
if (itemType === 'service') {
if (itemType === 'app') {
return {
...previous,
services: [
...previous.services.filter((x) => x.id !== itemId),
{ ...(currentItem as ServiceType) },
apps: [
...previous.apps.filter((x) => x.id !== itemId),
{ ...(currentItem as AppType) },
],
};
}

View File

@@ -94,7 +94,7 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
<Text color="dimmed" size="xs" align="center">
Only for
<br />
services &<br />
apps &<br />
integrations
</Text>
</Flex>
@@ -115,7 +115,7 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
<Text color="dimmed" size="xs" align="center">
Only for
<br />
services &<br />
apps &<br />
integrations
</Text>
</Flex>
@@ -126,13 +126,13 @@ export const LayoutSelector = ({ defaultLayout }: LayoutSelectorProps) => {
<Stack spacing="xs">
<Checkbox
label="Enable left sidebar"
description="Optional. Can be used for services and integrations only"
description="Optional. Can be used for apps and integrations only"
checked={leftSidebar}
onChange={(ev) => handleChange('enabledLeftSidebar', ev, setLeftSidebar)}
/>
<Checkbox
label="Enable right sidebar"
description="Optional. Can be used for services and integrations only"
description="Optional. Can be used for apps and integrations only"
checked={rightSidebar}
onChange={(ev) => handleChange('enabledRightSidebar', ev, setRightSidebar)}
/>

View File

@@ -16,7 +16,7 @@ import { IconBrandYoutube, IconDownload, IconMovie, IconSearch } from '@tabler/i
import axios from 'axios';
import { useTranslation } from 'next-i18next';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import SmallServiceItem from '../../AppShelf/SmallServiceItem';
import SmallAppItem from './SmallAppItem';
import Tip from '../Tip';
import { searchUrls } from '../../Settings/Common/SearchEngine/SearchEngineSelector';
import { useConfigContext } from '../../../config/provider';
@@ -56,12 +56,12 @@ export function Search() {
const [debounced, cancel] = useDebouncedValue(searchQuery, 250);
// TODO: ask manuel-rw about overseerr
// Answer: We can simply check if there is a service of the type overseer and display results if there is one.
// 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 = false; //config?.settings.common.enabledModules.overseerr;
const overseerrService = config?.services.find(
(service) =>
service.integration?.type === 'overseerr' || service.integration?.type === 'jellyseerr'
const overseerrApp = config?.apps.find(
(app) =>
app.integration?.type === 'overseerr' || app.integration?.type === 'jellyseerr'
);
const searchEngineSettings = config?.settings.common.searchEngine;
const searchEngineUrl = !searchEngineSettings
@@ -100,32 +100,32 @@ export function Search() {
},
{
icon: <IconMovie />,
disabled: !(isOverseerrEnabled === true && overseerrService !== undefined),
disabled: !(isOverseerrEnabled === true && overseerrApp !== undefined),
label: t('searchEngines.overseerr.name'),
value: 'overseerr',
description: t('searchEngines.overseerr.description'),
url: `${overseerrService?.url}search?query=`,
url: `${overseerrApp?.url}search?query=`,
shortcut: 'm',
},
];
const [selectedSearchEngine, setSearchEngine] = useState<ItemProps>(searchEnginesList[0]);
const matchingServices =
config?.services.filter((service) => {
const matchingApps =
config?.apps.filter((app) => {
if (searchQuery === '' || searchQuery === undefined) {
return false;
}
return service.name.toLowerCase().includes(searchQuery.toLowerCase());
return app.name.toLowerCase().includes(searchQuery.toLowerCase());
}) ?? [];
const autocompleteData = matchingServices.map((service) => ({
label: service.name,
value: service.name,
icon: service.appearance.iconUrl,
url: service.behaviour.onClickUrl ?? service.url,
const autocompleteData = matchingApps.map((app) => ({
label: app.name,
value: app.name,
icon: app.appearance.iconUrl,
url: app.behaviour.onClickUrl ?? app.url,
}));
const AutoCompleteItem = forwardRef<HTMLDivElement, any>(
({ label, value, icon, url, ...others }: any, ref) => (
<div ref={ref} {...others}>
<SmallServiceItem service={{ label, value, icon, url }} />
<SmallAppItem app={{ label, value, icon, url }} />
</div>
)
);

View File

@@ -0,0 +1,18 @@
import { Avatar, Group, Text } from '@mantine/core';
interface smallAppItem {
label: string;
icon?: string;
url?: string;
}
export default function SmallAppItem(props: any) {
const { app }: { app: smallAppItem } = props;
// TODO : Use Next/link
return (
<Group>
{app.icon && <Avatar src={app.icon} />}
<Text>{app.label}</Text>
</Group>
);
}

View File

@@ -52,10 +52,10 @@ export default function CalendarComponent(props: any) {
const [lidarrMedias, setLidarrMedias] = useState([] as any);
const [radarrMedias, setRadarrMedias] = useState([] as any);
const [readarrMedias, setReadarrMedias] = useState([] as any);
const sonarrServices = config.services.filter((service) => service.type === 'Sonarr');
const radarrServices = config.services.filter((service) => service.type === 'Radarr');
const lidarrServices = config.services.filter((service) => service.type === 'Lidarr');
const readarrServices = config.services.filter((service) => service.type === 'Readarr');
const sonarrServices = config.apps.filter((service) => service.type === 'Sonarr');
const radarrServices = config.apps.filter((service) => service.type === 'Radarr');
const lidarrServices = config.apps.filter((service) => service.type === 'Lidarr');
const readarrServices = config.apps.filter((service) => service.type === 'Readarr');
const today = new Date();
const { classes, cx } = useStyles();
@@ -125,7 +125,7 @@ export default function CalendarComponent(props: any) {
).then(() => {
setReadarrMedias(currentReadarrMedias);
});
}, [config.services]);
}, [config.apps]);
const weekStartsAtSunday =
(config?.modules?.[CalendarModule.id]?.options?.sundaystart?.value as boolean) ?? false;

View File

@@ -28,7 +28,7 @@ export interface IMedia {
export function OverseerrMediaDisplay(props: any) {
const { media }: { media: Result } = props;
const { config } = useConfig();
const service = config.services.find(
const service = config.apps.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
);
@@ -58,7 +58,7 @@ export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const readarr = config.services.find((service: serviceItem) => service.type === 'Readarr');
const readarr = config.apps.find((service: serviceItem) => service.type === 'Readarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!readarr) {
@@ -90,7 +90,7 @@ export function LidarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfig();
// Find lidarr in services
const lidarr = config.services.find((service: serviceItem) => service.type === 'Lidarr');
const lidarr = config.apps.find((service: serviceItem) => service.type === 'Lidarr');
// Find a poster CoverType
const poster = media.images.find((image: any) => image.coverType === 'cover');
if (!lidarr) {

View File

@@ -137,7 +137,7 @@ export function DashdotComponent() {
const dashConfig = config.modules?.[DashdotModule.id].options as typeof DashdotModule['options'];
const isCompact = dashConfig?.useCompactView?.value ?? false;
const dashdotService: serviceItem | undefined = config.services.filter(
const dashdotService: serviceItem | undefined = config.apps.filter(
(service) => service.type === 'Dash.'
)[0];
const dashdotUrl = dashdotService?.url ?? dashConfig?.url?.value ?? '';

View File

@@ -20,7 +20,7 @@ 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';
import { AppType } from '../../types/app';
let t: TFunction<'modules/docker', undefined>;
@@ -163,7 +163,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction
disabled={selected.length === 0 || selected.length > 1}
onClick={() => {
const containerUrl = `http://localhost:${selected[0].Ports[0].PublicPort}`;
openContextModalGeneric<{ service: ServiceType }>({
openContextModalGeneric<{ service: AppType }>({
modal: 'editService',
innerProps: {
service: {

View File

@@ -39,7 +39,7 @@ export const TorrentsModule: IModule = {
export default function TorrentsComponent() {
const { config } = useConfig();
const downloadServices =
config.services.filter(
config.apps.filter(
(service) =>
service.type === 'qBittorrent' ||
service.type === 'Transmission' ||

View File

@@ -30,7 +30,7 @@ export default function TotalDownloadsComponent() {
const setSafeInterval = useSetSafeInterval();
const { config } = useConfig();
const downloadServices =
config.services.filter(
config.apps.filter(
(service) =>
service.type === 'qBittorrent' ||
service.type === 'Transmission' ||
@@ -68,7 +68,7 @@ export default function TotalDownloadsComponent() {
clearInterval(interval);
});
}, 1000);
}, [config.services]);
}, [config.apps]);
useEffect(() => {
torrentHistoryHandlers.append({

View File

@@ -31,7 +31,7 @@ export const UsenetComponent: FunctionComponent = () => {
const { t } = useTranslation('modules/usenet');
const [selectedServiceId, setSelectedService] = useState<string | null>(downloadServices[0]?.id);
const { data } = useGetUsenetInfo({ serviceId: selectedServiceId! });
const { data } = useGetUsenetInfo({ appId: selectedServiceId! });
useEffect(() => {
if (!selectedServiceId && downloadServices.length) {
@@ -39,8 +39,8 @@ export const UsenetComponent: FunctionComponent = () => {
}
}, [downloadServices, selectedServiceId]);
const { mutate: pause } = usePauseUsenetQueue({ serviceId: selectedServiceId! });
const { mutate: resume } = useResumeUsenetQueue({ serviceId: selectedServiceId! });
const { mutate: pause } = usePauseUsenetQueue({ appId: selectedServiceId! });
const { mutate: resume } = useResumeUsenetQueue({ appId: selectedServiceId! });
if (downloadServices.length === 0) {
return (
@@ -98,10 +98,10 @@ export const UsenetComponent: FunctionComponent = () => {
/>
)}
<Tabs.Panel value="queue">
<UsenetQueueList serviceId={selectedServiceId} />
<UsenetQueueList appId={selectedServiceId} />
</Tabs.Panel>
<Tabs.Panel value="history">
<UsenetHistoryList serviceId={selectedServiceId} />
<UsenetHistoryList appId={selectedServiceId} />
</Tabs.Panel>
</Tabs>
);

View File

@@ -29,7 +29,7 @@ export async function getServerSideProps({
props: {
config: {
name: 'Default config',
services: [],
apps: [],
settings: {
searchUrl: 'https://www.google.com/search?q=',
},

View File

@@ -9,12 +9,12 @@ import { appWithTranslation } from 'next-i18next';
import { AppProps } from 'next/app';
import Head from 'next/head';
import { useState } from 'react';
import { ChangeAppPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeAppPositionModal';
import { ChangeIntegrationPositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeIntegrationPositionModal';
import { ChangeServicePositionModal } from '../components/Dashboard/Modals/ChangePosition/ChangeServicePositionModal';
import { EditServiceModal } from '../components/Dashboard/Modals/EditService/EditServiceModal';
import { EditAppModal } from '../components/Dashboard/Modals/EditAppModal/EditAppModal';
import { SelectElementModal } from '../components/Dashboard/Modals/SelectElement/SelectElementModal';
import { WidgetsRemoveModal } from '../components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { WidgetsEditModal } from '../components/Dashboard/Tiles/Widgets/WidgetsEditModal';
import { WidgetsRemoveModal } from '../components/Dashboard/Tiles/Widgets/WidgetsRemoveModal';
import { CategoryEditModal } from '../components/Dashboard/Wrappers/Category/CategoryEditModal';
import { ConfigProvider } from '../config/provider';
import '../styles/global.scss';
@@ -86,12 +86,12 @@ function App(this: any, props: AppProps & { colorScheme: ColorScheme }) {
<NotificationsProvider limit={4} position="bottom-left">
<ModalsProvider
modals={{
editService: EditServiceModal,
editApp: EditAppModal,
selectElement: SelectElementModal,
integrationOptions: WidgetsEditModal,
integrationRemove: WidgetsRemoveModal,
categoryEditModal: CategoryEditModal,
changeServicePositionModal: ChangeServicePositionModal,
changeAppPositionModal: ChangeAppPositionModal,
changeIntegrationPositionModal: ChangeIntegrationPositionModal,
}}
>

View File

@@ -1,7 +1,7 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../tools/config/getConfig';
import { ServiceIntegrationType } from '../../../types/service';
import { AppIntegrationType } from '../../../types/app';
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
@@ -15,7 +15,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
};
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
// Parse req.body as a AppItem
const {
month: monthString,
year: yearString,
@@ -34,20 +34,20 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const config = getConfig(configName);
const mediaServiceIntegrationTypes: ServiceIntegrationType['type'][] = [
const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [
'sonarr',
'radarr',
'readarr',
'lidarr',
];
const mediaServices = config.services.filter(
(service) =>
service.integration && mediaServiceIntegrationTypes.includes(service.integration.type)
const mediaApps = config.apps.filter(
(app) =>
app.integration && mediaAppIntegrationTypes.includes(app.integration.type)
);
const medias = await Promise.all(
await mediaServices.map(async (service) => {
const integration = service.integration!;
await mediaApps.map(async (app) => {
const integration = app.integration!;
const endpoint = IntegrationTypeEndpointMap.get(integration.type);
if (!endpoint) {
return {
@@ -57,7 +57,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
}
// Get the origin URL
let { href: origin } = new URL(service.url);
let { href: origin } = new URL(app.url);
if (origin.endsWith('/')) {
origin = origin.slice(0, -1);
}
@@ -85,7 +85,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
});
}
const IntegrationTypeEndpointMap = new Map<ServiceIntegrationType['type'], string>([
const IntegrationTypeEndpointMap = new Map<AppIntegrationType['type'], string>([
['sonarr', '/api/calendar'],
['radarr', '/api/v3/calendar'],
['lidarr', '/api/v1/calendar'],

View File

@@ -11,8 +11,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const { id, type } = req.query as { id: string; type: string };
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
const app = config.apps.find(
(app) => app.type === 'Overseerr' || app.type === 'Jellyseerr'
);
if (!id) {
return res.status(400).json({ error: 'No id provided' });
@@ -20,18 +20,18 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
if (!type) {
return res.status(400).json({ error: 'No type provided' });
}
if (!service?.apiKey) {
return res.status(400).json({ error: 'No service found' });
if (!app?.apiKey) {
return res.status(400).json({ error: 'No apps found' });
}
const serviceUrl = new URL(service.url);
const appUrl = new URL(app.url);
switch (type) {
case 'movie':
return axios
.get(`${serviceUrl.origin}/api/v1/movie/${id}`, {
.get(`${appUrl.origin}/api/v1/movie/${id}`, {
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
'X-Api-Key': app.apiKey,
},
})
.then((axiosres) => res.status(200).json(axiosres.data))
@@ -45,10 +45,10 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
case 'tv':
// Make request to the tv api
return axios
.get(`${serviceUrl.origin}/api/v1/tv/${id}`, {
.get(`${appUrl.origin}/api/v1/tv/${id}`, {
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
'X-Api-Key': app.apiKey,
},
})
.then((axiosres) => res.status(200).json(axiosres.data))
@@ -72,8 +72,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
const { seasons, type } = req.body as { seasons?: number[]; type: MediaType };
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
const app = config.apps.find(
(app) => app.type === 'Overseerr' || app.type === 'Jellyseerr'
);
if (!id) {
return res.status(400).json({ error: 'No id provided' });
@@ -81,13 +81,13 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
if (!type) {
return res.status(400).json({ error: 'No type provided' });
}
if (!service?.apiKey) {
return res.status(400).json({ error: 'No service found' });
if (!app?.apiKey) {
return res.status(400).json({ error: 'No app found' });
}
if (type === 'movie' && !seasons) {
return res.status(400).json({ error: 'No seasons provided' });
}
const serviceUrl = new URL(service.url);
const appUrl = new URL(app.url);
Consola.info('Got an Overseerr request with these arguments', {
mediaType: type,
mediaId: id,
@@ -95,7 +95,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
});
return axios
.post(
`${serviceUrl.origin}/api/v1/request`,
`${appUrl.origin}/api/v1/request`,
{
mediaType: type,
mediaId: Number(id),
@@ -104,7 +104,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
{
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
'X-Api-Key': app.apiKey,
},
}
)

View File

@@ -8,24 +8,24 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const { query } = req.query;
const service = config.services.find(
(service) => service.type === 'Overseerr' || service.type === 'Jellyseerr'
const app = config.apps.find(
(app) => app.type === 'Overseerr' || app.type === 'Jellyseerr'
);
// If query is an empty string, return an empty array
if (query === '' || query === undefined) {
return res.status(200).json([]);
}
if (!service || !query || service === undefined || !service.apiKey) {
if (!app || !query || app === undefined || !app.apiKey) {
return res.status(400).json({
error: 'Wrong request',
});
}
const serviceUrl = new URL(service.url);
const appUrl = new URL(app.url);
const data = await axios
.get(`${serviceUrl.origin}/api/v1/search?query=${query}`, {
.get(`${appUrl.origin}/api/v1/search?query=${query}`, {
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
'X-Api-Key': app.apiKey,
},
})
.then((res) => res.data);

View File

@@ -2,7 +2,7 @@ import ping from 'ping';
import { NextApiRequest, NextApiResponse } from 'next';
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a ServiceItem
// Parse req.body as a AppItem
const { url } = req.query;
// Parse url as URL object
const parsedUrl = new URL(url as string);

View File

@@ -8,50 +8,50 @@ import { getConfig } from '../../../tools/getConfig';
import { Config } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
// Get the type of service from the request url
// Get the type of app from the request url
const configName = getCookie('config-name', { req });
const { config }: { config: Config } = getConfig(configName?.toString() ?? 'default').props;
const qBittorrentServices = config.services.filter((service) => service.type === 'qBittorrent');
const delugeServices = config.services.filter((service) => service.type === 'Deluge');
const transmissionServices = config.services.filter((service) => service.type === 'Transmission');
const qBittorrentApp = config.apps.filter((app) => app.type === 'qBittorrent');
const delugeApp = config.apps.filter((app) => app.type === 'Deluge');
const transmissionApp = config.apps.filter((app) => app.type === 'Transmission');
const torrents: NormalizedTorrent[] = [];
if (!qBittorrentServices && !delugeServices && !transmissionServices) {
if (!qBittorrentApp && !delugeApp && !transmissionApp) {
return res.status(500).json({
statusCode: 500,
message: 'Missing services',
message: 'Missing apps',
});
}
try {
await Promise.all(
qBittorrentServices.map((service) =>
qBittorrentApp.map((apps) =>
new QBittorrent({
baseUrl: service.url,
username: service.username,
password: service.password,
baseUrl: apps.url,
username: apps.username,
password: apps.password,
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
await Promise.all(
delugeServices.map((service) =>
delugeApp.map((apps) =>
new Deluge({
baseUrl: service.url,
password: 'password' in service ? service.password : '',
baseUrl: apps.url,
password: 'password' in apps ? apps.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))
)
);
// Map transmissionServices
// Map transmissionApps
await Promise.all(
transmissionServices.map((service) =>
transmissionApp.map((apps) =>
new Transmission({
baseUrl: service.url,
username: 'username' in service ? service.username : '',
password: 'password' in service ? service.password : '',
baseUrl: apps.url,
username: 'username' in apps ? apps.username : '',
password: 'password' in apps ? apps.password : '',
})
.getAllData()
.then((e) => torrents.push(...e.torrents))

View File

@@ -11,7 +11,7 @@ import { UsenetHistoryItem } from '../../../../components/Dashboard/Tiles/UseNet
dayjs.extend(duration);
export interface UsenetHistoryRequestParams {
serviceId: string;
appId: string;
offset: number;
limit: number;
}
@@ -25,25 +25,25 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const config = getConfig(configName?.toString() ?? 'default');
const { limit, offset, serviceId } = req.query as any as UsenetHistoryRequestParams;
const { limit, offset, appId } = req.query as any as UsenetHistoryRequestParams;
const service = config.services.find((x) => x.id === serviceId);
const app = config.apps.find((x) => x.id === appId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
if (!app) {
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
}
let response: UsenetHistoryResponse;
switch (service.integration?.type) {
switch (app.integration?.type) {
case 'nzbGet': {
const url = new URL(service.url);
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
login:
service.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash:
service.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};
const nzbGet = NzbgetClient(options);
@@ -77,11 +77,11 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const { origin } = new URL(service.url);
const { origin } = new URL(app.url);
const apiKey = service.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
if (!apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
throw new Error(`API Key for app "${app.name}" is missing`);
}
const history = await new Client(origin, apiKey).history(offset, limit);
@@ -100,7 +100,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
default:
throw new Error(`Service type "${service.integration?.type}" unrecognized.`);
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
}
return res.status(200).json(response);

View File

@@ -3,16 +3,14 @@ import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
import { Config } from '../../../../tools/types';
import { NzbgetStatus } from './nzbget/types';
import { NzbgetClient } from './nzbget/nzbget-client';
import { getConfig } from '../../../../tools/config/getConfig';
import { NzbgetClient } from './nzbget/nzbget-client';
import { NzbgetStatus } from './nzbget/types';
dayjs.extend(duration);
export interface UsenetInfoRequestParams {
serviceId: string;
appId: string;
}
export interface UsenetInfoResponse {
@@ -26,25 +24,25 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const config = getConfig(configName?.toString() ?? 'default');
const { serviceId } = req.query as any as UsenetInfoRequestParams;
const { appId } = req.query as any as UsenetInfoRequestParams;
const service = config.services.find((x) => x.id === serviceId);
const app = config.apps.find((x) => x.id === appId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
if (!app) {
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
}
let response: UsenetInfoResponse;
switch (service.integration?.type) {
switch (app.integration?.type) {
case 'nzbGet': {
const url = new URL(service.url);
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
login:
service.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash:
service.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};
const nzbGet = NzbgetClient(options);
@@ -74,12 +72,12 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = service.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
if (!apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
throw new Error(`API Key for app "${app.name}" is missing`);
}
const { origin } = new URL(service.url);
const { origin } = new URL(app.url);
const queue = await new Client(origin, apiKey).queue(0, -1);
@@ -99,7 +97,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
default:
throw new Error(`Service type "${service.integration?.type}" unrecognized.`);
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
}
return res.status(200).json(response);

View File

@@ -3,19 +3,19 @@ import { NzbgetClientOptions } from './types';
export function NzbgetClient(options: NzbgetClientOptions) {
if (!options?.host) {
throw new Error('Cannot connect to NZBGet. Missing host in service config.');
throw new Error('Cannot connect to NZBGet. Missing host in app config.');
}
if (!options?.port) {
throw new Error('Cannot connect to NZBGet. Missing port in service config.');
throw new Error('Cannot connect to NZBGet. Missing port in app config.');
}
if (!options?.login) {
throw new Error('Cannot connect to NZBGet. Missing username in service config.');
throw new Error('Cannot connect to NZBGet. Missing username in app config.');
}
if (!options?.hash) {
throw new Error('Cannot connect to NZBGet. Missing password in service config.');
throw new Error('Cannot connect to NZBGet. Missing password in app config.');
}
return new NZBGet(options);

View File

@@ -9,32 +9,32 @@ import { NzbgetClient } from './nzbget/nzbget-client';
dayjs.extend(duration);
export interface UsenetPauseRequestParams {
serviceId: string;
appId: string;
}
async function Post(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const config = getConfig(configName?.toString() ?? 'default');
const { serviceId } = req.query as any as UsenetPauseRequestParams;
const { appId } = req.query as any as UsenetPauseRequestParams;
const service = config.services.find((x) => x.id === serviceId);
const app = config.apps.find((x) => x.id === appId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
if (!app) {
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
}
let result;
switch (service.integration?.type) {
switch (app.integration?.type) {
case 'nzbGet': {
const url = new URL(service.url);
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
login:
service.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash:
service.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};
const nzbGet = NzbgetClient(options);
@@ -51,18 +51,18 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = service.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
if (!apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
throw new Error(`API Key for app "${app.name}" is missing`);
}
const { origin } = new URL(service.url);
const { origin } = new URL(app.url);
result = await new Client(origin, apiKey).queuePause();
break;
}
default:
throw new Error(`Service type "${service.integration?.type}" unrecognized.`);
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
}
return res.status(200).json(result);

View File

@@ -11,7 +11,7 @@ import { NzbgetQueueItem, NzbgetStatus } from './nzbget/types';
dayjs.extend(duration);
export interface UsenetQueueRequestParams {
serviceId: string;
appId: string;
offset: number;
limit: number;
}
@@ -25,25 +25,25 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const config = getConfig(configName?.toString() ?? 'default');
const { limit, offset, serviceId } = req.query as any as UsenetQueueRequestParams;
const { limit, offset, appId } = req.query as any as UsenetQueueRequestParams;
const service = config.services.find((x) => x.id === serviceId);
const app = config.apps.find((x) => x.id === appId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
if (!app) {
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
}
let response: UsenetQueueResponse;
switch (service.integration?.type) {
switch (app.integration?.type) {
case 'nzbGet': {
const url = new URL(service.url);
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
login:
service.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash:
service.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};
const nzbGet = NzbgetClient(options);
@@ -93,12 +93,12 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = service.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
if (!apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
throw new Error(`API Key for app "${app.name}" is missing`);
}
const { origin } = new URL(service.url);
const { origin } = new URL(app.url);
const queue = await new Client(origin, apiKey).queue(offset, limit);
const items: UsenetQueueItem[] = queue.slots.map((slot) => {
@@ -126,7 +126,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) {
break;
}
default:
throw new Error(`Service type "${service.integration?.type}" unrecognized.`);
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
}
return res.status(200).json(response);

View File

@@ -4,14 +4,12 @@ import duration from 'dayjs/plugin/duration';
import { NextApiRequest, NextApiResponse } from 'next';
import { Client } from 'sabnzbd-api';
import { getConfig } from '../../../../tools/config/getConfig';
import { getServiceById } from '../../../../tools/hooks/useGetServiceByType';
import { Config } from '../../../../tools/types';
import { NzbgetClient } from './nzbget/nzbget-client';
dayjs.extend(duration);
export interface UsenetResumeRequestParams {
serviceId: string;
appId: string;
nzbId?: string;
}
@@ -19,25 +17,25 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
try {
const configName = getCookie('config-name', { req });
const config = getConfig(configName?.toString() ?? 'default');
const { serviceId } = req.query as any as UsenetResumeRequestParams;
const { appId } = req.query as any as UsenetResumeRequestParams;
const service = config.services.find((x) => x.id === serviceId);
const app = config.apps.find((x) => x.id === appId);
if (!service) {
throw new Error(`Service with ID "${req.query.serviceId}" could not be found.`);
if (!app) {
throw new Error(`App with ID "${req.query.appId}" could not be found.`);
}
let result;
switch (service.integration?.type) {
switch (app.integration?.type) {
case 'nzbGet': {
const url = new URL(service.url);
const url = new URL(app.url);
const options = {
host: url.hostname,
port: url.port,
login:
service.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined,
hash:
service.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined,
};
const nzbGet = NzbgetClient(options);
@@ -54,18 +52,18 @@ async function Post(req: NextApiRequest, res: NextApiResponse) {
break;
}
case 'sabnzbd': {
const apiKey = service.integration.properties.find((x) => x.field === 'apiKey')?.value;
const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value;
if (!apiKey) {
throw new Error(`API Key for service "${service.name}" is missing`);
throw new Error(`API Key for app "${app.name}" is missing`);
}
const { origin } = new URL(service.url);
const { origin } = new URL(app.url);
result = await new Client(origin, apiKey).queueResume();
break;
}
default:
throw new Error(`Service type "${service.integration?.type}" unrecognized.`);
throw new Error(`App type "${app.integration?.type}" unrecognized.`);
}
return res.status(200).json(result);

View File

@@ -43,8 +43,8 @@ export default async function addToHomarr(
) {
setConfig({
...config,
services: [
...config.services,
apps: [
...config.apps,
{
name: container.Names[0].substring(1),
id: container.Id,

View File

@@ -7,7 +7,7 @@ export const getFallbackConfig = (name?: string): BackendConfigType => ({
},
categories: [],
integrations: {},
services: [],
apps: [],
settings: {
common: {
searchEngine: {

View File

@@ -6,7 +6,7 @@ export const getFrontendConfig = (name: string): ConfigType => {
return {
...config,
services: config.services.map((s) => ({
apps: config.apps.map((s) => ({
...s,
integration: s.integration
? {

View File

@@ -18,7 +18,7 @@ const POLLING_INTERVAL = 2000;
export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
useQuery(
['usenetInfo', params.serviceId],
['usenetInfo', params.appId],
async () =>
(
await axios.get<UsenetInfoResponse>('/api/modules/usenet', {
@@ -29,7 +29,7 @@ export const useGetUsenetInfo = (params: UsenetInfoRequestParams) =>
refetchInterval: POLLING_INTERVAL,
keepPreviousData: true,
retry: 2,
enabled: !!params.serviceId,
enabled: !!params.appId,
}
);
@@ -80,14 +80,14 @@ export const usePauseUsenetQueue = (params: UsenetPauseRequestParams) =>
).data,
{
async onMutate() {
await queryClient.cancelQueries(['usenetInfo', params.serviceId]);
await queryClient.cancelQueries(['usenetInfo', params.appId]);
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
'usenetInfo',
params.serviceId,
params.appId,
]);
if (previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.serviceId], {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.appId], {
...previousInfo,
paused: true,
});
@@ -98,13 +98,13 @@ export const usePauseUsenetQueue = (params: UsenetPauseRequestParams) =>
onError(err, _, context) {
if (context?.previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(
['usenetInfo', params.serviceId],
['usenetInfo', params.appId],
context.previousInfo
);
}
},
onSettled() {
queryClient.invalidateQueries(['usenetInfo', params.serviceId]);
queryClient.invalidateQueries(['usenetInfo', params.appId]);
},
}
);
@@ -124,14 +124,14 @@ export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
).data,
{
async onMutate() {
await queryClient.cancelQueries(['usenetInfo', params.serviceId]);
await queryClient.cancelQueries(['usenetInfo', params.appId]);
const previousInfo = queryClient.getQueryData<UsenetInfoResponse>([
'usenetInfo',
params.serviceId,
params.appId,
]);
if (previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.serviceId], {
queryClient.setQueryData<UsenetInfoResponse>(['usenetInfo', params.appId], {
...previousInfo,
paused: false,
});
@@ -142,13 +142,13 @@ export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) =>
onError(err, _, context) {
if (context?.previousInfo) {
queryClient.setQueryData<UsenetInfoResponse>(
['usenetInfo', params.serviceId],
['usenetInfo', params.appId],
context.previousInfo
);
}
},
onSettled() {
queryClient.invalidateQueries(['usenetInfo', params.serviceId]);
queryClient.invalidateQueries(['usenetInfo', params.appId]);
},
}
);

View File

@@ -8,7 +8,7 @@ export const useGetServiceByType = (...serviceTypes: ServiceType[]) => {
};
export const getServiceByType = (config: Config, ...serviceTypes: ServiceType[]) =>
config.services.filter((s) => serviceTypes.includes(s.type));
config.apps.filter((s) => serviceTypes.includes(s.type));
export const getServiceById = (config: Config, id: string) =>
config.services.find((s) => s.id === id);
config.apps.find((s) => s.id === id);

View File

@@ -3,12 +3,12 @@ import { Config } from './types';
export function migrateToIdConfig(config: Config): Config {
// Set the config and add an ID to all the services that don't have one
const services = config.services.map((service) => ({
const services = config.apps.map((service) => ({
...service,
id: service.id ?? uuidv4(),
}));
return {
...config,
services,
apps: services,
};
}

View File

@@ -15,7 +15,7 @@ type configContextType = {
const configContext = createContext<configContextType>({
config: {
name: 'default',
services: [],
apps: [],
settings: {
searchUrl: 'https://google.com/search?q=',
},
@@ -41,7 +41,7 @@ type Props = {
export function ConfigProvider({ children }: Props) {
const [config, setConfigInternal] = useState<Config>({
name: 'default',
services: [],
apps: [],
settings: {
searchUrl: 'https://www.google.com/search?q=',
},

View File

@@ -20,7 +20,7 @@ export interface Settings {
export interface Config {
name: string;
services: serviceItem[];
apps: serviceItem[];
settings: Settings;
modules: {
[key: string]: ConfigModule;

View File

@@ -1,31 +1,31 @@
import { IconKey, IconPassword, IconUser, TablerIcon } from '@tabler/icons';
import { TileBaseType } from './tile';
export interface ServiceType extends TileBaseType {
export interface AppType extends TileBaseType {
id: string;
name: string;
url: string;
behaviour: ServiceBehaviourType;
network: ServiceNetworkType;
appearance: ServiceAppearanceType;
integration: ServiceIntegrationType;
behaviour: AppBehaviourType;
network: AppNetworkType;
appearance: AppAppearanceType;
integration: AppIntegrationType;
}
export type ConfigServiceType = Omit<ServiceType, 'integration'> & {
integration?: ConfigServiceIntegrationType | null;
export type ConfigAppType = Omit<AppType, 'integration'> & {
integration?: ConfigAppIntegrationType | null;
};
interface ServiceBehaviourType {
interface AppBehaviourType {
onClickUrl: string;
isOpeningNewTab: boolean;
}
interface ServiceNetworkType {
interface AppNetworkType {
enabledStatusChecker: boolean;
okStatus: number[];
}
interface ServiceAppearanceType {
interface AppAppearanceType {
iconUrl: string;
}
@@ -42,28 +42,28 @@ export type IntegrationType =
| 'transmission'
| 'nzbGet';
export type ServiceIntegrationType = {
export type AppIntegrationType = {
type: IntegrationType | null;
properties: ServiceIntegrationPropertyType[];
properties: AppIntegrationPropertyType[];
};
export type ConfigServiceIntegrationType = Omit<ServiceIntegrationType, 'properties'> & {
properties: ConfigServiceIntegrationPropertyType[];
export type ConfigAppIntegrationType = Omit<AppIntegrationType, 'properties'> & {
properties: ConfigAppIntegrationPropertyType[];
};
export type ServiceIntegrationPropertyType = {
export type AppIntegrationPropertyType = {
type: 'private' | 'public';
field: IntegrationField;
value?: string | undefined;
isDefined: boolean;
};
type ConfigServiceIntegrationPropertyType = Omit<ServiceIntegrationPropertyType, 'isDefined'>;
type ConfigAppIntegrationPropertyType = Omit<AppIntegrationPropertyType, 'isDefined'>;
export type IntegrationField = 'apiKey' | 'password' | 'username';
export const integrationFieldProperties: {
[key in ServiceIntegrationType['type']]: IntegrationField[];
[key in AppIntegrationType['type']]: IntegrationField[];
} = {
lidarr: ['apiKey'],
radarr: ['apiKey'],

View File

@@ -1,6 +1,6 @@
import { CategoryType } from './category';
import { WrapperType } from './wrapper';
import { ConfigServiceType, ServiceType } from './service';
import { ConfigAppType, AppType } from './app';
import { IntegrationsType } from './integration';
import { SettingsType } from './settings';
@@ -9,13 +9,13 @@ export interface ConfigType {
configProperties: ConfigPropertiesType;
categories: CategoryType[];
wrappers: WrapperType[];
services: ServiceType[];
apps: AppType[];
integrations: IntegrationsType;
settings: SettingsType;
}
export type BackendConfigType = Omit<ConfigType, 'services'> & {
services: ConfigServiceType[];
export type BackendConfigType = Omit<ConfigType, 'apps'> & {
apps: ConfigAppType[];
};
export interface ConfigPropertiesType {

View File

@@ -60,7 +60,7 @@ export const DashDotTile = ({ module, className }: DashDotTileProps) => {
{menu}
<div>
{heading}
<p>{t('card.errors.noService')}</p>
<p>{t('card.errors.noApp')}</p>
</div>
</HomarrCardWrapper>
);

View File

@@ -19,7 +19,7 @@ import { useTranslation } from 'next-i18next';
import { UsenetQueueList } from './UsenetQueueList';
import { UsenetHistoryList } from './UsenetHistoryList';
import { BaseTileProps } from '../../components/Dashboard/Tiles/type';
import { ServiceIntegrationType } from '../../types/service';
import { AppIntegrationType } from '../../types/app';
import { useConfigContext } from '../../config/provider';
import { useGetUsenetInfo, usePauseUsenetQueue, useResumeUsenetQueue } from '../../tools/hooks/api';
import { HomarrCardWrapper } from '../../components/Dashboard/Tiles/HomarrCardWrapper';
@@ -27,31 +27,31 @@ import { humanFileSize } from '../../tools/humanFileSize';
dayjs.extend(duration);
const downloadServiceTypes: ServiceIntegrationType['type'][] = ['sabnzbd', 'nzbGet'];
const downloadAppTypes: AppIntegrationType['type'][] = ['sabnzbd', 'nzbGet'];
interface UseNetTileProps extends BaseTileProps {}
export const UseNetTile = ({ className }: UseNetTileProps) => {
const { t } = useTranslation('modules/usenet');
const { config } = useConfigContext();
const downloadServices =
config?.services.filter(
(x) => x.integration && downloadServiceTypes.includes(x.integration.type)
const downloadApps =
config?.apps.filter(
(x) => x.integration && downloadAppTypes.includes(x.integration.type)
) ?? [];
const [selectedServiceId, setSelectedService] = useState<string | null>(downloadServices[0]?.id);
const { data } = useGetUsenetInfo({ serviceId: selectedServiceId! });
const [selectedAppId, setSelectedApp] = useState<string | null>(downloadApps[0]?.id);
const { data } = useGetUsenetInfo({ appId: selectedAppId! });
useEffect(() => {
if (!selectedServiceId && downloadServices.length) {
setSelectedService(downloadServices[0].id);
if (!selectedAppId && downloadApps.length) {
setSelectedApp(downloadApps[0].id);
}
}, [downloadServices, selectedServiceId]);
}, [downloadApps, selectedAppId]);
const { mutate: pause } = usePauseUsenetQueue({ serviceId: selectedServiceId! });
const { mutate: resume } = useResumeUsenetQueue({ serviceId: selectedServiceId! });
const { mutate: pause } = usePauseUsenetQueue({ appId: selectedAppId! });
const { mutate: resume } = useResumeUsenetQueue({ appId: selectedAppId! });
if (downloadServices.length === 0) {
if (downloadApps.length === 0) {
return (
<HomarrCardWrapper className={className}>
<Stack>
@@ -64,7 +64,7 @@ export const UseNetTile = ({ className }: UseNetTileProps) => {
);
}
if (!selectedServiceId) {
if (!selectedAppId) {
return null;
}
@@ -90,16 +90,16 @@ export const UseNetTile = ({ className }: UseNetTileProps) => {
</Group>
)}
</Tabs.List>
{downloadServices.length > 1 && (
{downloadApps.length > 1 && (
<Select
value={selectedServiceId}
onChange={setSelectedService}
value={selectedAppId}
onChange={setSelectedApp}
ml="xs"
data={downloadServices.map((service) => ({ value: service.id, label: service.name }))}
data={downloadApps.map((app) => ({ value: app.id, label: app.name }))}
/>
)}
<Tabs.Panel value="queue">
<UsenetQueueList serviceId={selectedServiceId} />
<UsenetQueueList appId={selectedAppId} />
{!data ? null : data.paused ? (
<Button uppercase onClick={() => resume()} radius="xl" size="xs" fullWidth mt="sm">
<IconPlayerPlay size={12} style={{ marginRight: 5 }} /> {t('info.paused')}
@@ -112,7 +112,7 @@ export const UseNetTile = ({ className }: UseNetTileProps) => {
)}
</Tabs.Panel>
<Tabs.Panel value="history" style={{ display: 'flex', flexDirection: 'column' }}>
<UsenetHistoryList serviceId={selectedServiceId} />
<UsenetHistoryList appId={selectedAppId} />
</Tabs.Panel>
</Tabs>
</HomarrCardWrapper>

View File

@@ -25,12 +25,12 @@ import { parseDuration } from '../../tools/parseDuration';
dayjs.extend(duration);
interface UsenetHistoryListProps {
serviceId: string;
appId: string;
}
const PAGE_SIZE = 10;
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ serviceId }) => {
export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ appId }) => {
const [page, setPage] = useState(1);
const { t } = useTranslation(['modules/usenet', 'common']);
@@ -39,7 +39,7 @@ export const UsenetHistoryList: FunctionComponent<UsenetHistoryListProps> = ({ s
const { data, isLoading, isError, error } = useGetUsenetHistory({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
serviceId,
appId: appId,
});
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);

View File

@@ -27,12 +27,12 @@ import { humanFileSize } from '../../tools/humanFileSize';
dayjs.extend(duration);
interface UsenetQueueListProps {
serviceId: string;
appId: string;
}
const PAGE_SIZE = 10;
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ serviceId }) => {
export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ appId }) => {
const theme = useMantineTheme();
const { t } = useTranslation('modules/usenet');
const progressbarBreakpoint = theme.breakpoints.xs;
@@ -44,7 +44,7 @@ export const UsenetQueueList: FunctionComponent<UsenetQueueListProps> = ({ servi
const { data, isLoading, isError, error } = useGetUsenetDownloads({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
serviceId,
appId: appId,
});
const totalPages = Math.ceil((data?.total || 1) / PAGE_SIZE);

View File

@@ -1114,7 +1114,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/carousel@npm:^5.9.0":
"@mantine/carousel@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/carousel@npm:5.9.3"
dependencies:
@@ -1128,7 +1128,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/core@npm:^5.9.0":
"@mantine/core@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/core@npm:5.9.3"
dependencies:
@@ -1145,7 +1145,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/dates@npm:^5.9.0":
"@mantine/dates@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/dates@npm:5.9.3"
dependencies:
@@ -1159,7 +1159,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/dropzone@npm:^5.9.0":
"@mantine/dropzone@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/dropzone@npm:5.9.3"
dependencies:
@@ -1174,7 +1174,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/form@npm:^5.9.0":
"@mantine/form@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/form@npm:5.9.3"
dependencies:
@@ -1186,7 +1186,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/hooks@npm:^5.9.0":
"@mantine/hooks@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/hooks@npm:5.9.3"
peerDependencies:
@@ -1195,7 +1195,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/modals@npm:^5.9.0":
"@mantine/modals@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/modals@npm:5.9.3"
dependencies:
@@ -1209,7 +1209,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/next@npm:^5.9.0":
"@mantine/next@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/next@npm:5.9.3"
dependencies:
@@ -1223,7 +1223,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/notifications@npm:^5.9.0":
"@mantine/notifications@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/notifications@npm:5.9.3"
dependencies:
@@ -1238,7 +1238,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/prism@npm:^5.9.0":
"@mantine/prism@npm:^5.9.3":
version: 5.9.3
resolution: "@mantine/prism@npm:5.9.3"
dependencies:
@@ -4536,7 +4536,7 @@ __metadata:
"fsevents@patch:fsevents@^2.3.2#~builtin<compat/fsevents>, fsevents@patch:fsevents@~2.3.2#~builtin<compat/fsevents>":
version: 2.3.2
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin<compat/fsevents>::version=2.3.2&hash=18f3a7"
resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin<compat/fsevents>::version=2.3.2&hash=df0bf1"
dependencies:
node-gyp: latest
conditions: os=darwin
@@ -4900,16 +4900,16 @@ __metadata:
"@dnd-kit/utilities": ^3.2.0
"@emotion/react": ^11.10.5
"@emotion/server": ^11.10.0
"@mantine/carousel": ^5.9.0
"@mantine/core": ^5.9.0
"@mantine/dates": ^5.9.0
"@mantine/dropzone": ^5.9.0
"@mantine/form": ^5.9.0
"@mantine/hooks": ^5.9.0
"@mantine/modals": ^5.9.0
"@mantine/next": ^5.9.0
"@mantine/notifications": ^5.9.0
"@mantine/prism": ^5.9.0
"@mantine/carousel": ^5.9.3
"@mantine/core": ^5.9.3
"@mantine/dates": ^5.9.3
"@mantine/dropzone": ^5.9.3
"@mantine/form": ^5.9.3
"@mantine/hooks": ^5.9.3
"@mantine/modals": ^5.9.3
"@mantine/next": ^5.9.3
"@mantine/notifications": ^5.9.3
"@mantine/prism": ^5.9.3
"@next/bundle-analyzer": ^12.1.4
"@next/eslint-plugin-next": ^12.1.4
"@nivo/core": ^0.79.0
@@ -7489,7 +7489,7 @@ __metadata:
"resolve@patch:resolve@^1.19.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.20.0#~builtin<compat/resolve>, resolve@patch:resolve@^1.22.0#~builtin<compat/resolve>":
version: 1.22.1
resolution: "resolve@patch:resolve@npm%3A1.22.1#~builtin<compat/resolve>::version=1.22.1&hash=07638b"
resolution: "resolve@patch:resolve@npm%3A1.22.1#~builtin<compat/resolve>::version=1.22.1&hash=c3c19d"
dependencies:
is-core-module: ^2.9.0
path-parse: ^1.0.7
@@ -7502,7 +7502,7 @@ __metadata:
"resolve@patch:resolve@^2.0.0-next.3#~builtin<compat/resolve>":
version: 2.0.0-next.4
resolution: "resolve@patch:resolve@npm%3A2.0.0-next.4#~builtin<compat/resolve>::version=2.0.0-next.4&hash=07638b"
resolution: "resolve@patch:resolve@npm%3A2.0.0-next.4#~builtin<compat/resolve>::version=2.0.0-next.4&hash=c3c19d"
dependencies:
is-core-module: ^2.9.0
path-parse: ^1.0.7
@@ -8338,7 +8338,7 @@ __metadata:
"typescript@patch:typescript@^4.7.4#~builtin<compat/typescript>":
version: 4.9.4
resolution: "typescript@patch:typescript@npm%3A4.9.4#~builtin<compat/typescript>::version=4.9.4&hash=7ad353"
resolution: "typescript@patch:typescript@npm%3A4.9.4#~builtin<compat/typescript>::version=4.9.4&hash=d73830"
bin:
tsc: bin/tsc
tsserver: bin/tsserver