Merge branch 'gridstack' of https://github.com/manuel-rw/homarr into gridstack

This commit is contained in:
Meierschlumpf
2022-12-18 22:59:29 +01:00
69 changed files with 661 additions and 495 deletions

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

@@ -4,11 +4,11 @@ import dashDotDefinition from '../../../widgets/dashDot/DashDotTile';
import useNetDefinition from '../../../widgets/useNet/UseNetTile';
import weatherDefinition 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 any | 'service']: {
[key in keyof any | 'app']: {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
@@ -18,8 +18,8 @@ type TileDefinitionProps = {
};
export const Tiles: TileDefinitionProps = {
service: {
component: ServiceTile,
app: {
component: AppTile,
minWidth: 2,
maxWidth: 12,
minHeight: 2,

View File

@@ -29,19 +29,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

@@ -31,19 +31,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

@@ -19,19 +19,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>
);
}