Merge pull request #788 from ajnart/allow-multiple-widgets-of-same-type

Allow multiple widgets of same type
This commit is contained in:
Thomas Camlong
2023-04-04 20:29:59 +09:00
committed by GitHub
27 changed files with 590 additions and 497 deletions

View File

@@ -1,387 +1,389 @@
{
"schemaVersion": 1,
"configProperties": {
"name": "default"
},
"categories": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f",
"position": 1,
"name": "Welcome to Homarr 🎉",
"type": "category"
"schemaVersion": 1,
"configProperties": {
"name": "default"
},
"categories": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f",
"position": 1,
"name": "Welcome to Homarr 🎉",
"type": "category"
}
],
"wrappers": [
{
"id": "default",
"position": 0
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a326",
"position": 1
}
],
"apps": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Discord",
"url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": {
"onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningNewTab": true,
"externalUrl": "https://discord.com/invite/aCsmEV5RgA"
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 3,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://ko-fi.com/ajnart",
"behaviour": {
"onClickUrl": "https://ko-fi.com/ajnart",
"externalUrl": "https://ko-fi.com/ajnart",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
"name": "Contribute",
"url": "https://github.com/ajnart/homarr",
"behaviour": {
"onClickUrl": "https://github.com/ajnart/homarr",
"externalUrl": "https://github.com/ajnart/homarr",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": []
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 2
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 2
}
}
}
},
{
"id": "5df743d9-5cb1-457c-85d2-64ff86855652",
"name": "Documentation",
"url": "https://homarr.dev",
"behaviour": {
"onClickUrl": "https://homarr.dev",
"externalUrl": "https://homarr.dev",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"widgets": [
{
"id": "971aa859-8570-49a1-8d34-dd5c7b3638d1",
"type": "date",
"properties": {
"display24HourFormat": true
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"sm": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"md": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
},
{
"id": "e3004052-6b83-480e-b458-56e8ccdca5f0",
"type": "weather",
"properties": {
"displayInFahrenheit": false,
"location": "Paris"
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"settings": {
"common": {
"searchEngine": {
"type": "google",
"properties": {}
}
},
"customization": {
"layout": {
"enabledLeftSidebar": false,
"enabledRightSidebar": false,
"enabledDocker": false,
"enabledPing": false,
"enabledSearchbar": true
},
"pageTitle": "Homarr v0.11 ⭐️",
"logoImageUrl": "/imgs/logo/logo.png",
"faviconUrl": "/imgs/favicon/favicon-squared",
"backgroundImageUrl": "",
"customCss": "",
"colors": {
"primary": "red",
"secondary": "yellow",
"shade": 7
},
"appOpacity": 100
}
}
],
"wrappers": [
{
"id": "default",
"position": 0
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a326",
"position": 1
}
],
"apps": [
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a337",
"name": "Discord",
"url": "https://discord.com/invite/aCsmEV5RgA",
"behaviour": {
"onClickUrl": "https://discord.com/invite/aCsmEV5RgA",
"isOpeningNewTab": true,
"externalUrl": "https://discord.com/invite/aCsmEV5RgA"
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/discord.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 3,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a990",
"name": "Donate",
"url": "https://ko-fi.com/ajnart",
"behaviour": {
"onClickUrl": "https://ko-fi.com/ajnart",
"externalUrl": "https://ko-fi.com/ajnart",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/ko-fi.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
},
"sm": {
"location": {
"x": 2,
"y": 2
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 3,
"y": 1
},
"size": {
"width": 1,
"height": 1
}
}
}
},
{
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a330",
"name": "Contribute",
"url": "https://github.com/ajnart/homarr",
"behaviour": {
"onClickUrl": "https://github.com/ajnart/homarr",
"externalUrl": "https://github.com/ajnart/homarr",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": []
},
"appearance": {
"iconUrl": "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/github.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 2
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 2
}
}
}
},
{
"id": "5df743d9-5cb1-457c-85d2-64ff86855652",
"name": "Documentation",
"url": "https://homarr.dev",
"behaviour": {
"onClickUrl": "https://homarr.dev",
"externalUrl": "https://homarr.dev",
"isOpeningNewTab": true
},
"network": {
"enabledStatusChecker": false,
"okStatus": [
200
]
},
"appearance": {
"iconUrl": "/imgs/logo/logo.png"
},
"integration": {
"type": null,
"properties": []
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 1,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"widgets": [
{
"id": "date",
"properties": {
"display24HourFormat": true
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"sm": {
"location": {
"x": 0,
"y": 1
},
"size": {
"width": 2,
"height": 1
}
},
"md": {
"location": {
"x": 4,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 2,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
},
{
"id": "weather",
"properties": {
"displayInFahrenheit": false,
"location": "Paris"
},
"area": {
"type": "category",
"properties": {
"id": "47af36c0-47c1-4e5b-bfc7-ad645ee6a33f"
}
},
"shape": {
"md": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"sm": {
"location": {
"x": 1,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
},
"lg": {
"location": {
"x": 0,
"y": 0
},
"size": {
"width": 2,
"height": 1
}
}
}
}
],
"settings": {
"common": {
"searchEngine": {
"type": "google",
"properties": {}
}
},
"customization": {
"layout": {
"enabledLeftSidebar": false,
"enabledRightSidebar": false,
"enabledDocker": false,
"enabledPing": false,
"enabledSearchbar": true
},
"pageTitle": "Homarr v0.11 ⭐️",
"logoImageUrl": "/imgs/logo/logo.png",
"faviconUrl": "/imgs/favicon/favicon-squared",
"backgroundImageUrl": "",
"customCss": "",
"colors": {
"primary": "red",
"secondary": "yellow",
"shade": 7
},
"appOpacity": 100
}
}
}
}

View File

@@ -67,6 +67,7 @@
"uuid": "^8.3.2",
"xml-js": "^1.6.11",
"yarn": "^1.22.19",
"zod": "^3.21.4",
"zustand": "^4.1.4"
},
"devDependencies": {

View File

@@ -54,8 +54,8 @@ export const ChangeWidgetPositionModal = ({
closeModal(id);
};
const widthData = useWidthData(innerProps.widgetId);
const heightData = useHeightData(innerProps.widgetId);
const widthData = useWidthData(innerProps.widgetType);
const heightData = useHeightData(innerProps.widgetType);
return (
<ChangePositionModal

View File

@@ -1,6 +1,5 @@
import { Grid, Text } from '@mantine/core';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '../../../../../../config/provider';
import widgets from '../../../../../../widgets';
import { SelectorBackArrow } from '../Shared/SelectorBackArrow';
import { WidgetElementType } from './WidgetElementType';
@@ -13,7 +12,6 @@ export const AvailableIntegrationElements = ({
onClickBack,
}: AvailableIntegrationElementsProps) => {
const { t } = useTranslation('layout/element-selector/selector');
const activeWidgets = useConfigContext().config?.widgets ?? [];
return (
<>
<SelectorBackArrow onClickBack={onClickBack} />
@@ -23,11 +21,9 @@ export const AvailableIntegrationElements = ({
</Text>
<Grid>
{Object.entries(widgets)
.filter(([widgetId]) => !activeWidgets.some((aw) => aw.id === widgetId))
.map(([k, v]) => (
<WidgetElementType key={k} id={k} image={v.icon} widget={v} />
))}
{Object.entries(widgets).map(([k, v]) => (
<WidgetElementType key={k} id={k} image={v.icon} widget={v} />
))}
</Grid>
</>
);

View File

@@ -2,6 +2,7 @@ import { useModals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconChecks, TablerIcon } from '@tabler/icons';
import { useTranslation } from 'next-i18next';
import { v4 as uuidv4 } from 'uuid';
import { useConfigContext } from '../../../../../../config/provider';
import { useConfigStore } from '../../../../../../config/store';
import { IWidget, IWidgetDefinition } from '../../../../../../widgets/widgets';
@@ -32,9 +33,10 @@ export const WidgetElementType = ({ id, image, disabled, widget }: WidgetElement
(prev) => ({
...prev,
widgets: [
...prev.widgets.filter((w) => w.id !== widget.id),
...prev.widgets,
{
id: widget.id,
id: uuidv4(),
type: widget.id,
properties: Object.entries(widget.options).reduce((prev, [k, v]) => {
const newPrev = prev;
newPrev[k] = v.defaultValue;

View File

@@ -26,6 +26,7 @@ import { DraggableList } from './DraggableList';
export type WidgetEditModalInnerProps = {
widgetId: string;
widgetType: string;
options: IWidget<string, any>['properties'];
widgetOptions: IWidget<string, any>['properties'];
};
@@ -37,7 +38,7 @@ export const WidgetsEditModal = ({
id,
innerProps,
}: ContextModalProps<WidgetEditModalInnerProps>) => {
const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']);
const { t } = useTranslation([`modules/${innerProps.widgetType}`, 'common']);
const [moduleProperties, setModuleProperties] = useState(innerProps.options);
const items = Object.entries(innerProps.widgetOptions ?? {}) as [
string,
@@ -45,7 +46,7 @@ export const WidgetsEditModal = ({
][];
// Find the Key in the "Widgets" Object that matches the widgetId
const currentWidgetDefinition = Widgets[innerProps.widgetId as keyof typeof Widgets];
const currentWidgetDefinition = Widgets[innerProps.widgetType as keyof typeof Widgets];
const { name: configName } = useConfigContext();
const updateConfig = useConfigStore((x) => x.updateConfig);
@@ -100,7 +101,7 @@ export const WidgetsEditModal = ({
<WidgetOptionTypeSwitch
key={`${key}.${index}`}
option={option}
widgetId={innerProps.widgetId}
widgetId={innerProps.widgetType}
propName={key}
value={value}
handleChange={handleChange}

View File

@@ -10,6 +10,7 @@ import { WidgetsRemoveModalInnerProps } from './WidgetsRemoveModal';
export type WidgetChangePositionModalInnerProps = {
widgetId: string;
widgetType: string;
widget: IWidget<string, any>;
wrapperColumnCount: number;
};
@@ -27,8 +28,8 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
// Match widget.id with WidgetsDefinitions
// First get the keys
const keys = Object.keys(WidgetsDefinitions);
// Then find the key that matches the widget.id
const widgetDefinition = keys.find((key) => key === widget.id);
// Then find the key that matches the widget.type
const widgetDefinition = keys.find((key) => key === widget.type);
// Then get the widget definition
const widgetDefinitionObject =
WidgetsDefinitions[widgetDefinition as keyof typeof WidgetsDefinitions];
@@ -38,13 +39,8 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
modal: 'integrationRemove',
title: <Title order={4}>{t('common:remove')}</Title>,
innerProps: {
widgetId: integration,
},
styles: {
inner: {
position: 'sticky',
top: 30,
},
widgetId: widget.id,
widgetType: integration,
},
});
};
@@ -55,16 +51,11 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
size: 'xl',
title: null,
innerProps: {
widgetId: integration,
widgetId: widget.id,
widgetType: integration,
widget,
wrapperColumnCount,
},
styles: {
inner: {
position: 'sticky',
top: 30,
},
},
});
};
@@ -73,19 +64,13 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
modal: 'integrationOptions',
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
innerProps: {
widgetId: integration,
widgetId: widget.id,
widgetType: integration,
options: widget.properties,
// Cast as the right type for the correct widget
widgetOptions: widgetDefinitionObject.options as any,
},
zIndex: 5,
styles: {
inner: {
position: 'sticky',
top: 30,
maxHeight: '100%',
},
},
});
};

View File

@@ -7,6 +7,7 @@ import { useConfigStore } from '../../../../config/store';
export type WidgetsRemoveModalInnerProps = {
widgetId: string;
widgetType: string;
};
export const WidgetsRemoveModal = ({
@@ -14,7 +15,7 @@ export const WidgetsRemoveModal = ({
id,
innerProps,
}: ContextModalProps<WidgetsRemoveModalInnerProps>) => {
const { t } = useTranslation([`modules/${innerProps.widgetId}`, 'common']);
const { t } = useTranslation([`modules/${innerProps.widgetType}`, 'common']);
const { name: configName } = useConfigContext();
if (!configName) return null;
const updateConfig = useConfigStore((x) => x.updateConfig);
@@ -35,7 +36,7 @@ export const WidgetsRemoveModal = ({
<Trans
i18nKey="common:removeConfirm"
components={[<Text weight={500} />]}
values={{ item: innerProps.widgetId }}
values={{ item: innerProps.widgetType }}
/>
<Group position="right">
<Button onClick={() => context.closeModal(id)} variant="light">

View File

@@ -42,7 +42,7 @@ export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) {
);
})}
{widgets.map((widget) => {
const definition = Widgets[widget.id as keyof typeof Widgets] as
const definition = Widgets[widget.type as keyof typeof Widgets] as
| IWidgetDefinition
| undefined;
if (!definition) return null;
@@ -52,7 +52,7 @@ export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) {
type="widget"
key={widget.id}
itemRef={refs.items.current[widget.id]}
id={definition.id}
id={widget.id}
{...definition.gridstack}
{...widget.shape[shapeSize]?.location}
{...widget.shape[shapeSize]?.size}
@@ -60,7 +60,7 @@ export function WrapperContent({ apps, refs, widgets }: WrapperContentProps) {
<WidgetWrapper
className="grid-stack-item-content"
widget={widget}
widgetId={widget.id}
widgetType={widget.type}
WidgetComponent={definition.component}
/>
</GridstackTileWrapper>

View File

@@ -48,6 +48,7 @@ export const useConfigStore = create<UseConfigStoreType>((set, get) => ({
const previousConfig: ConfigType = JSON.parse(JSON.stringify(currentConfig.value));
const updatedConfig = updateCallback(currentConfig.value);
set((old) => ({
...old,
configs: [

View File

@@ -153,7 +153,7 @@ export function RadarrMediaDisplay(props: any) {
export function SonarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfigContext();
const calendar = config?.widgets.find((w) => w.id === 'calendar');
const calendar = config?.widgets.find((w) => w.type === 'calendar');
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
// Find a poster CoverType

View File

@@ -4,6 +4,7 @@ import Consola from 'consola';
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import { AppIntegrationType } from '../../../types/app';
import { getConfig } from '../../../tools/config/getConfig';
@@ -18,28 +19,36 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
});
};
const getQuerySchema = z.object({
month: z
.string()
.regex(/^\d+$/)
.transform((x) => parseInt(x, 10)),
year: z
.string()
.regex(/^\d+$/)
.transform((x) => parseInt(x, 10)),
widgetId: z.string().uuid(),
configName: z.string(),
});
async function Get(req: NextApiRequest, res: NextApiResponse) {
// Parse req.body as a AppItem
const {
month: monthString,
year: yearString,
configName,
} = req.query as { month: string; year: string; configName: string };
const parseResult = getQuerySchema.safeParse(req.query);
const month = parseInt(monthString, 10);
const year = parseInt(yearString, 10);
if (Number.isNaN(month) || Number.isNaN(year) || !configName) {
if (!parseResult.success) {
return res.status(400).json({
statusCode: 400,
message: 'Missing required parameter in url: year, month or configName',
message: 'Invalid query parameters, please specify the widgetId, month, year and configName',
});
}
// Parse req.body as a AppItem
const { month, year, widgetId, configName } = parseResult.data;
const config = getConfig(configName);
// Find the calendar widget in the config
const calendar = config.widgets.find((w) => w.id === 'calendar');
const calendar = config.widgets.find((w) => w.type === 'calendar' && w.id === widgetId);
const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false;
const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [

View File

@@ -1,20 +1,29 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import { getConfig } from '../../../../tools/config/getConfig';
import { IDashDotTile } from '../../../../widgets/dashDot/DashDotTile';
async function Get(req: NextApiRequest, res: NextApiResponse) {
const { configName } = req.query;
const getQuerySchema = z.object({
configName: z.string(),
widgetId: z.string().uuid(),
});
if (!configName || typeof configName !== 'string') {
async function Get(req: NextApiRequest, res: NextApiResponse) {
const parseResult = getQuerySchema.safeParse(req.query);
if (!parseResult.success) {
return res.status(400).json({
message: 'Missing required configName in url',
statusCode: 400,
message: 'Invalid query parameters, please specify the widgetId and configName',
});
}
const { configName, widgetId } = parseResult.data;
const config = getConfig(configName);
const dashDotWidget = config.widgets.find((x) => x.id === 'dashdot');
const dashDotWidget = config.widgets.find((x) => x.type === 'dashdot' && x.id === widgetId);
if (!dashDotWidget) {
return res.status(400).json({

View File

@@ -1,19 +1,28 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';
import { getConfig } from '../../../../tools/config/getConfig';
import { IDashDotTile } from '../../../../widgets/dashDot/DashDotTile';
async function Get(req: NextApiRequest, res: NextApiResponse) {
const { configName } = req.query;
const getQuerySchema = z.object({
configName: z.string(),
widgetId: z.string().uuid(),
});
if (!configName || typeof configName !== 'string') {
async function Get(req: NextApiRequest, res: NextApiResponse) {
const parseResult = getQuerySchema.safeParse(req.query);
if (!parseResult.success) {
return res.status(400).json({
message: 'Missing required configName in url',
statusCode: 400,
message: 'Invalid query parameters, please specify the widgetId and configName',
});
}
const { configName, widgetId } = parseResult.data;
const config = getConfig(configName);
const dashDotWidget = config.widgets.find((x) => x.id === 'dashdot');
const dashDotWidget = config.widgets.find((x) => x.type === 'dashdot' && x.id === widgetId);
if (!dashDotWidget) {
return res.status(400).json({

View File

@@ -8,6 +8,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
import Parser from 'rss-parser';
import { z } from 'zod';
import { getConfig } from '../../../../tools/config/getConfig';
import { IRssWidget } from '../../../../widgets/rss/RssWidgetTile';
import { Stopwatch } from '../../../../tools/shared/time/stopwatch.tool';
@@ -25,11 +26,24 @@ const parser: Parser<any, CustomItem> = new Parser({
},
});
const getQuerySchema = z.object({
widgetId: z.string().uuid(),
});
export const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const rssWidget = config.widgets.find((x) => x.id === 'rss') as IRssWidget | undefined;
const parseResult = getQuerySchema.safeParse(request.query);
if (!parseResult.success) {
response.status(400).json({ message: 'invalid query parameters, please specify the widgetId' });
return;
}
const rssWidget = config.widgets.find(
(x) => x.type === 'rss' && x.id === parseResult.data.widgetId
) as IRssWidget | undefined;
if (
!rssWidget ||

View File

@@ -1,9 +1,13 @@
import Consola from 'consola';
import { v4 as uuidv4 } from 'uuid';
import { BackendConfigType, ConfigType } from '../../types/config';
import { backendMigrateConfig } from './backendMigrateConfig';
import { configExists } from './configExists';
import { getFallbackConfig } from './getFallbackConfig';
import { readConfig } from './readConfig';
import { writeConfig } from './writeConfig';
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export const getConfig = (name: string): BackendConfigType => {
if (!configExists(name)) return getFallbackConfig() as unknown as ConfigType;
@@ -12,9 +16,29 @@ export const getConfig = (name: string): BackendConfigType => {
// to the new format.
const config = readConfig(name);
if (config.schemaVersion === undefined) {
Consola.log('Migrating config file...', config);
Consola.log('Migrating config file...', config.name);
return backendMigrateConfig(config, name);
}
return config;
let backendConfig = config as BackendConfigType;
if (backendConfig.widgets.some((widget) => !uuidRegex.test(widget.id))) {
backendConfig = {
...backendConfig,
widgets: backendConfig.widgets.map((widget) => ({
...widget,
id: uuidRegex.test(widget.id) ? widget.id : uuidv4(),
type: !uuidRegex.test(widget.id) ? widget.id : widget.type,
})),
};
Consola.log(
'Migrating config file to multiple widgets...',
backendConfig.configProperties.name
);
writeConfig(backendConfig);
}
return backendConfig;
};

View File

@@ -183,7 +183,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
case 'torrent-status':
case 'Torrent':
return {
id: 'torrents-status',
id: uuidv4(),
type: 'torrents-status',
properties: {
refreshInterval: 10,
displayCompletedTorrents: oldModule.options?.hideComplete?.value ?? false,
@@ -199,7 +200,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
} as ITorrent;
case 'weather':
return {
id: 'weather',
id: uuidv4(),
type: 'weather',
properties: {
displayInFahrenheit: oldModule.options?.freedomunit?.value ?? false,
location: oldModule.options?.location?.value ?? 'Paris',
@@ -216,7 +218,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
case 'Dash.': {
const oldDashDotService = config.services.find((service) => service.type === 'Dash.');
return {
id: 'dashdot',
id: uuidv4(),
type: 'dashdot',
properties: {
url: oldModule.options?.url?.value ?? oldDashDotService?.url ?? '',
cpuMultiView: oldModule.options?.cpuMultiView?.value ?? false,
@@ -235,7 +238,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
}
case 'date':
return {
id: 'date',
id: uuidv4(),
type: 'date',
properties: {
display24HourFormat: oldModule.options?.full?.value ?? true,
},
@@ -249,7 +253,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
} as IDateWidget;
case 'Download Speed' || 'dlspeed':
return {
id: 'dlspeed',
id: uuidv4(),
type: 'dlspeed',
properties: {},
area: {
type: 'wrapper',
@@ -261,7 +266,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
} as ITorrentNetworkTraffic;
case 'calendar':
return {
id: 'calendar',
id: uuidv4(),
type: 'calendar',
properties: {},
area: {
type: 'wrapper',
@@ -273,7 +279,8 @@ const migrateModules = (config: Config): IWidget<string, any>[] => {
} as ICalendarWidget;
case 'usenet':
return {
id: 'usenet',
id: uuidv4(),
type: 'usenet',
properties: {},
area: {
type: 'wrapper',

View File

@@ -0,0 +1,10 @@
import fs from 'fs';
import { BackendConfigType } from '../../types/config';
import { generateConfigPath } from './generateConfigPath';
export function writeConfig(config: BackendConfigType) {
const path = generateConfigPath(config.configProperties.name);
return fs.writeFileSync(path, JSON.stringify(config, null, 4), {
encoding: 'utf8',
});
}

View File

@@ -1,4 +1,4 @@
import { ComponentType, useMemo } from 'react';
import { ComponentType } from 'react';
import Widgets from '.';
import { HomarrCardWrapper } from '../components/Dashboard/Tiles/HomarrCardWrapper';
import { WidgetsMenu } from '../components/Dashboard/Tiles/Widgets/WidgetsMenu';
@@ -6,7 +6,7 @@ import ErrorBoundary from './boundary';
import { IWidget } from './widgets';
interface WidgetWrapperProps {
widgetId: string;
widgetType: string;
widget: IWidget<string, any>;
className: string;
WidgetComponent: ComponentType<{ widget: IWidget<string, any> }>;
@@ -14,26 +14,24 @@ interface WidgetWrapperProps {
// If a property has no value, set it to the default value
const useWidget = <T extends IWidget<string, any>>(widget: T): T => {
const definition = Widgets[widget.id as keyof typeof Widgets];
const definition = Widgets[widget.type as keyof typeof Widgets];
return useMemo(() => {
const newProps = { ...widget.properties };
const newProps = { ...widget.properties };
Object.entries(definition.options).forEach(([key, option]) => {
if (newProps[key] == null) {
newProps[key] = option.defaultValue;
}
});
Object.entries(definition.options).forEach(([key, option]) => {
if (newProps[key] == null) {
newProps[key] = option.defaultValue;
}
});
return {
...widget,
properties: newProps,
};
}, [widget]);
return {
...widget,
properties: newProps,
};
};
export const WidgetWrapper = ({
widgetId,
widgetType,
widget,
className,
WidgetComponent,
@@ -43,7 +41,7 @@ export const WidgetWrapper = ({
return (
<ErrorBoundary>
<HomarrCardWrapper className={className}>
<WidgetsMenu integration={widgetId} widget={widgetWithDefaultProps} />
<WidgetsMenu integration={widgetType} widget={widgetWithDefaultProps} />
<WidgetComponent widget={widgetWithDefaultProps} />
</HomarrCardWrapper>
</ErrorBoundary>

View File

@@ -94,7 +94,7 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
mt="md"
fullWidth
>
{(this.props.t('modal.reportButton'))}
{this.props.t('modal.reportButton')}
</Button>
</>
),

View File

@@ -63,7 +63,7 @@ function CalendarTile({ widget }: CalendarTileProps) {
await fetch(
`/api/modules/calendar?year=${month.getFullYear()}&month=${
month.getMonth() + 1
}&configName=${configName}`
}&configName=${configName}&widgetId=${widget.id}`
)
).json()) as MediasType,
});

View File

@@ -9,11 +9,12 @@ import { DashDotInfo } from './DashDotCompactNetwork';
interface DashDotCompactStorageProps {
info: DashDotInfo;
widgetId: string;
}
export const DashDotCompactStorage = ({ info }: DashDotCompactStorageProps) => {
export const DashDotCompactStorage = ({ info, widgetId }: DashDotCompactStorageProps) => {
const { t } = useTranslation('modules/dashdot');
const { data: storageLoad } = useDashDotStorage();
const { data: storageLoad } = useDashDotStorage(widgetId);
const totalUsed = calculateTotalLayoutSize({
layout: storageLoad?.layout ?? [],
@@ -50,7 +51,7 @@ interface CalculateTotalLayoutSizeProps<TLayoutItem> {
key: keyof TLayoutItem;
}
const useDashDotStorage = () => {
const useDashDotStorage = (widgetId: string) => {
const { name: configName, config } = useConfigContext();
return useQuery({
@@ -58,17 +59,18 @@ const useDashDotStorage = () => {
'dashdot/storage',
{
configName,
url: config?.widgets.find((x) => x.id === 'dashdot')?.properties.url,
url: config?.widgets.find((x) => x.type === 'dashdot')?.properties.url,
widgetId,
},
],
queryFn: () => fetchDashDotStorageLoad(configName),
queryFn: () => fetchDashDotStorageLoad(configName, widgetId),
});
};
async function fetchDashDotStorageLoad(configName: string | undefined) {
async function fetchDashDotStorageLoad(configName: string | undefined, widgetId: string) {
if (!configName) throw new Error('configName is undefined');
return (await (
await axios.get('/api/modules/dashdot/storage', { params: { configName } })
await axios.get('/api/modules/dashdot/storage', { params: { configName, widgetId } })
).data) as DashDotStorageLoad;
}

View File

@@ -11,6 +11,7 @@ interface DashDotGraphProps {
dashDotUrl: string;
usePercentages: boolean;
info: DashDotInfo;
widgetId: string;
}
export const DashDotGraph = ({
@@ -21,12 +22,13 @@ export const DashDotGraph = ({
dashDotUrl,
usePercentages,
info,
widgetId,
}: DashDotGraphProps) => {
const { t } = useTranslation('modules/dashdot');
const { classes } = useStyles();
if (graph === 'storage' && isCompact) {
return <DashDotCompactStorage info={info} />;
return <DashDotCompactStorage info={info} widgetId={widgetId} />;
}
if (graph === 'network' && isCompact) {

View File

@@ -160,6 +160,7 @@ function DashDotTile({ widget }: DashDotTileProps) {
const { data: info } = useDashDotInfo({
dashDotUrl,
enabled: !detectedProtocolDowngrade,
widgetId: widget.id,
});
if (detectedProtocolDowngrade) {
@@ -197,6 +198,7 @@ function DashDotTile({ widget }: DashDotTileProps) {
isCompact={g.subValues.compactView ?? false}
multiView={g.subValues.multiView ?? false}
usePercentages={usePercentages}
widgetId={widget.id}
/>
</Grid.Col>
))}
@@ -207,7 +209,15 @@ function DashDotTile({ widget }: DashDotTileProps) {
);
}
const useDashDotInfo = ({ dashDotUrl, enabled }: { dashDotUrl: string; enabled: boolean }) => {
const useDashDotInfo = ({
dashDotUrl,
enabled,
widgetId,
}: {
dashDotUrl: string;
enabled: boolean;
widgetId: string;
}) => {
const { name: configName } = useConfigContext();
return useQuery({
refetchInterval: 50000,
@@ -218,15 +228,15 @@ const useDashDotInfo = ({ dashDotUrl, enabled }: { dashDotUrl: string; enabled:
dashDotUrl,
},
],
queryFn: () => fetchDashDotInfo(configName),
queryFn: () => fetchDashDotInfo(configName, widgetId),
enabled,
});
};
const fetchDashDotInfo = async (configName: string | undefined) => {
const fetchDashDotInfo = async (configName: string | undefined, widgetId: string) => {
if (!configName) return {} as DashDotInfo;
return (await (
await axios.get('/api/modules/dashdot/info', { params: { configName } })
await axios.get('/api/modules/dashdot/info', { params: { configName, widgetId } })
).data) as DashDotInfo;
};

View File

@@ -29,8 +29,8 @@ import dayjs from 'dayjs';
import { useTranslation } from 'next-i18next';
import Link from 'next/link';
import { useState } from 'react';
import { defineWidget } from '../helper';
import { IWidget } from '../widgets';
import { defineWidget } from '../helper';
const definition = defineWidget({
id: 'rss',
@@ -56,11 +56,11 @@ interface RssTileProps {
widget: IRssWidget;
}
const useGetRssFeed = (feedUrl: string) =>
export const useGetRssFeed = (feedUrl: string, widgetId: string) =>
useQuery({
queryKey: ['rss-feed', feedUrl],
queryFn: async () => {
const response = await fetch('/api/modules/rss');
const response = await fetch(`/api/modules/rss?widgetId=${widgetId}`);
return response.json();
},
});
@@ -68,7 +68,8 @@ const useGetRssFeed = (feedUrl: string) =>
function RssTile({ widget }: RssTileProps) {
const { t } = useTranslation('modules/rss');
const { data, isLoading, isFetching, isError, refetch } = useGetRssFeed(
widget.properties.rssFeedUrl
widget.properties.rssFeedUrl,
widget.id
);
const { classes } = useStyles();
const [loadingOverlayVisible, setLoadingOverlayVisible] = useState(false);

View File

@@ -13,7 +13,8 @@ import { ShapeType } from '../types/shape';
// Type of widgets which are saved to config
export type IWidget<TKey extends string, TDefinition extends IWidgetDefinition> = {
id: TKey;
id: string;
type: TKey;
properties: {
[key in keyof TDefinition['options']]: MakeLessSpecific<
TDefinition['options'][key]['defaultValue']

View File

@@ -4914,6 +4914,7 @@ __metadata:
vitest-fetch-mock: ^0.2.2
xml-js: ^1.6.11
yarn: ^1.22.19
zod: ^3.21.4
zustand: ^4.1.4
languageName: unknown
linkType: soft
@@ -8760,6 +8761,13 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.21.4":
version: 3.21.4
resolution: "zod@npm:3.21.4"
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
languageName: node
linkType: hard
"zustand@npm:^4.1.4":
version: 4.3.6
resolution: "zustand@npm:4.3.6"