feat(settings): add simple-ping settings (#2118)

This commit is contained in:
Meier Lukas
2025-02-07 22:10:35 +01:00
committed by GitHub
parent c04c42dc8a
commit dff6cb9d31
88 changed files with 4489 additions and 582 deletions

View File

@@ -18,6 +18,7 @@
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0", "@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0", "@homarr/cron-job-status": "workspace:^0.1.0",

View File

@@ -5,12 +5,13 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background"; import { BoardBackgroundVideo } from "~/components/layout/background";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { useIsBoardReady, useRequiredBoard } from "./_context"; import { useIsBoardReady } from "./_ready-context";
let boardName: string | null = null; let boardName: string | null = null;

View File

@@ -1,118 +0,0 @@
"use client";
import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"];
isReady: boolean;
markAsReady: (id: string) => void;
isEditMode: boolean;
setEditMode: Dispatch<SetStateAction<boolean>>;
} | null>(null);
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]);
const [isEditMode, setEditMode] = useState(false);
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
// Invalidate the board when the pathname changes
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
useEffect(() => {
return () => {
setReadySections([]);
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return (
<BoardContext.Provider
value={{
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
isEditMode,
setEditMode,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.isReady;
};
export const useRequiredBoard = () => {
const optionalBoard = useOptionalBoard();
if (!optionalBoard) {
throw new Error("Board is required");
}
return optionalBoard;
};
export const useOptionalBoard = () => {
const context = useContext(BoardContext);
return context?.board;
};
export const useEditMode = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return [context.isEditMode, context.setEditMode] as const;
};

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRequiredBoard } from "./_context"; import { useRequiredBoard } from "@homarr/boards/context";
export const CustomCss = () => { export const CustomCss = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();

View File

@@ -20,6 +20,8 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -32,7 +34,6 @@ import { CategoryEditModal } from "~/components/board/sections/category/category
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env"; import { env } from "~/env";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
@@ -119,7 +120,7 @@ const AddMenu = () => {
}; };
const EditModeMenu = () => { const EditModeMenu = () => {
const [isEditMode, setEditMode] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit"); const t = useScopedI18n("board.action.edit");
@@ -131,7 +132,7 @@ const EditModeMenu = () => {
}); });
void utils.board.getBoardByName.invalidate({ name: board.name }); void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathActionAsync(`/boards/${board.name}`); void revalidatePathActionAsync(`/boards/${board.name}`);
setEditMode(false); close();
}, },
onError() { onError() {
showErrorNotification({ showErrorNotification({
@@ -143,8 +144,8 @@ const EditModeMenu = () => {
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board); if (isEditMode) return saveBoard(board);
setEditMode(true); open();
}, [board, isEditMode, saveBoard, setEditMode]); }, [board, isEditMode, saveBoard, open]);
useHotkeys([["mod+e", toggle]]); useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);

View File

@@ -0,0 +1,67 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
const BoardReadyContext = createContext<{
isReady: boolean;
markAsReady: (id: string) => void;
} | null>(null);
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const board = useRequiredBoard();
const [readySections, setReadySections] = useState<string[]>([]);
// Reset sections required for ready state
useEffect(() => {
return () => {
setReadySections([]);
};
}, [pathname, utils]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return (
<BoardReadyContext.Provider
value={{
isReady: board.sections.length === readySections.length,
markAsReady,
}}
>
{children}
</BoardReadyContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.isReady;
};

View File

@@ -4,10 +4,10 @@ import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core"; import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
import { useColorSchemeManager } from "../../_client-providers/mantine"; import { useColorSchemeManager } from "../../_client-providers/mantine";
import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({ export const BoardMantineProvider = ({
children, children,

View File

@@ -0,0 +1,48 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const BehaviorSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
disableStatus: board.disableStatus,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};

View File

@@ -5,11 +5,11 @@ import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core"; import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal"; import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css"; import classes from "./danger.module.css";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => { export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {

View File

@@ -5,13 +5,13 @@ import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks"; import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react"; import { IconAlertTriangle } from "@tabler/icons-react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation"; import { validation } from "@homarr/validation";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared"; import { useSavePartialSettingsMutation } from "./_shared";
interface Props { interface Props {

View File

@@ -4,6 +4,7 @@ import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text
import { import {
IconAlertTriangle, IconAlertTriangle,
IconBrush, IconBrush,
IconClick,
IconFileTypeCss, IconFileTypeCss,
IconLayout, IconLayout,
IconPhoto, IconPhoto,
@@ -23,6 +24,7 @@ import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { BackgroundSettingsContent } from "./_background"; import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access"; import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors"; import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss"; import { CustomCssSettingsContent } from "./_customCss";
@@ -95,6 +97,9 @@ export default async function BoardSettingsPage(props: Props) {
<AccordionItemFor value="customCss" icon={IconFileTypeCss}> <AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} /> <CustomCssSettingsContent board={board} />
</AccordionItemFor> </AccordionItemFor>
<AccordionItemFor value="behavior" icon={IconClick}>
<BehaviorSettingsContent board={board} />
</AccordionItemFor>
{hasFullAccess && ( {hasFullAccess && (
<> <>
<AccordionItemFor value="access" icon={IconUser}> <AccordionItemFor value="access" icon={IconUser}>

View File

@@ -2,8 +2,9 @@
import { IconLayoutBoard } from "@tabler/icons-react"; import { IconLayoutBoard } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "./(content)/_context";
export const BoardOtherHeaderActions = () => { export const BoardOtherHeaderActions = () => {
const board = useRequiredBoard(); const board = useRequiredBoard();

View File

@@ -3,6 +3,8 @@ import { notFound } from "next/navigation";
import { AppShellMain } from "@mantine/core"; import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
@@ -10,9 +12,9 @@ import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types"; import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css"; import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme"; import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
@@ -42,17 +44,21 @@ export const createBoardLayout = <TParams extends Params>({
return ( return (
<BoardProvider initialBoard={initialBoard}> <BoardProvider initialBoard={initialBoard}>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardReadyProvider>
<CustomCss /> <EditModeProvider>
<ClientShell hasNavigation={false}> <BoardMantineProvider defaultColorScheme={colorScheme}>
<MainHeader <CustomCss />
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} <ClientShell hasNavigation={false}>
actions={headerActions} <MainHeader
hasNavigation={false} logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
/> actions={headerActions}
<AppShellMain>{children}</AppShellMain> hasNavigation={false}
</ClientShell> />
</BoardMantineProvider> <AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
</BoardProvider> </BoardProvider>
); );
}; };

View File

@@ -94,6 +94,8 @@ export default async function Layout(props: {
board: { board: {
homeBoardId: serverSettings.board.homeBoardId, homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId, mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
}, },
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId }, search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
}} }}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Group, Text } from "@mantine/core"; import { Group, Switch, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react"; import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -56,6 +56,18 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
)} )}
{...form.getInputProps("mobileHomeBoardId")} {...form.getInputProps("mobileHomeBoardId")}
/> />
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</> </>
)} )}
</CommonSettingsForm> </CommonSettingsForm>

View File

@@ -9,6 +9,7 @@ import { ErrorBoundary } from "react-error-boundary";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications"; import { showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation"; import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
@@ -29,6 +30,7 @@ interface WidgetPreviewPageContentProps {
} }
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => { export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
const settings = useSettings();
const t = useScopedI18n("widgetPreview"); const t = useScopedI18n("widgetPreview");
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal); const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal); const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
@@ -43,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
integrationIds: string[]; integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
}>({ }>({
options: reduceWidgetOptionsWithDefaultValues(kind, {}), options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
integrationIds: [], integrationIds: [],
advancedOptions: { advancedOptions: {
customCssClasses: [], customCssClasses: [],
@@ -63,8 +65,9 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
}); });
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]); }, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
const Comp = loadWidgetDynamic(kind); const Comp = loadWidgetDynamic(kind);

View File

@@ -1,9 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation"; import type { BoardItemAdvancedOptions } from "@homarr/validation";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { CreateItemInput } from "./actions/create-item"; import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item"; import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item"; import type { DuplicateItemInput } from "./actions/duplicate-item";

View File

@@ -5,11 +5,13 @@ import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors"; import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import classes from "../sections/item.module.css"; import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { BoardItemMenu } from "./item-menu"; import { BoardItemMenu } from "./item-menu";
@@ -53,11 +55,12 @@ interface InnerContentProps {
} }
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind); const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind]; const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const newItem = { ...item, options }; const newItem = { ...item, options };
const { updateItemOptions } = useItemActions(); const { updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) => const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>

View File

@@ -3,13 +3,14 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useSectionContext } from "../sections/section-context"; import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal"; import { ItemMoveModal } from "./item-move-modal";
@@ -35,6 +36,7 @@ export const BoardItemMenu = ({
const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs; const { gridstack } = useSectionContext().refs;
const settings = useSettings();
// Reset error boundary on next render if item has been edited // Reset error boundary on next render if item has been edited
useEffect(() => { useEffect(() => {
@@ -75,6 +77,7 @@ export const BoardItemMenu = ({
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), (currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
}); });
}; };

View File

@@ -3,9 +3,9 @@ import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CategorySection } from "~/app/[locale]/boards/_types"; import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { CategoryMenu } from "./category/category-menu"; import { CategoryMenu } from "./category/category-menu";
import { GridStack } from "./gridstack/gridstack"; import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css"; import classes from "./item.module.css";

View File

@@ -1,9 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types"; import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category"; import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category"; import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category"; import type { RemoveCategoryInput } from "./actions/remove-category";

View File

@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client"; import { fetchApi } from "@homarr/api/client";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import type { CategorySection } from "~/app/[locale]/boards/_types"; import type { CategorySection } from "~/app/[locale]/boards/_types";
@@ -99,8 +100,9 @@ export const useCategoryMenuActions = (category: CategorySection) => {
); );
}, [category, openModal, renameCategory, t]); }, [category, openModal, renameCategory, t]);
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => { const openAllInNewTabs = useCallback(async () => {
const appIds = filterByItemKind(category.items, "app").map((item) => { const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
return item.options.appId; return item.options.appId;
}); });
@@ -119,7 +121,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
}); });
break; break;
} }
}, [category, t, openConfirmModal]); }, [category, t, openConfirmModal, settings]);
return { return {
addCategoryAbove, addCategoryAbove,

View File

@@ -13,12 +13,12 @@ import {
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { MaybePromise } from "@homarr/common/types"; import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import type { CategorySection } from "~/app/[locale]/boards/_types"; import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useCategoryMenuActions } from "./category-menu-actions"; import { useCategoryMenuActions } from "./category-menu-actions";
interface Props { interface Props {

View File

@@ -1,14 +1,23 @@
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings";
import type { WidgetComponentProps } from "@homarr/widgets"; import type { WidgetComponentProps } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = <TKind extends WidgetKind>(items: Item[], kind: TKind) => { export const filterByItemKind = <TKind extends WidgetKind>(
items: Item[],
settings: SettingsContextProps,
kind: TKind,
) => {
return items return items
.filter((item) => item.kind === kind) .filter((item) => item.kind === kind)
.map((item) => ({ .map((item) => ({
...item, ...item,
options: reduceWidgetOptionsWithDefaultValues(kind, item.options) as WidgetComponentProps<TKind>["options"], options: reduceWidgetOptionsWithDefaultValues(
kind,
settings,
item.options,
) as WidgetComponentProps<TKind>["options"],
})); }));
}; };

View File

@@ -1,7 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types"; import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { BoardItemContent } from "../items/item-content"; import { BoardItemContent } from "../items/item-content";
import { BoardDynamicSection } from "./dynamic-section"; import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item"; import { GridStackItem } from "./gridstack/gridstack-item";

View File

@@ -1,7 +1,8 @@
import { Box, Card } from "@mantine/core"; import { Box, Card } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSection } from "~/app/[locale]/boards/_types"; import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu"; import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack"; import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css"; import classes from "./item.module.css";

View File

@@ -1,9 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client"; import { createId } from "@homarr/db/client";
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types"; import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface RemoveDynamicSection { interface RemoveDynamicSection {
id: string; id: string;

View File

@@ -1,11 +1,11 @@
import { ActionIcon, Menu } from "@mantine/core"; import { ActionIcon, Menu } from "@mantine/core";
import { IconDotsVertical, IconTrash } from "@tabler/icons-react"; import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSection } from "~/app/[locale]/boards/_types"; import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useDynamicSectionActions } from "./dynamic-actions"; import { useDynamicSectionActions } from "./dynamic-actions";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => { export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {

View File

@@ -1,7 +1,8 @@
import combineClasses from "clsx"; import combineClasses from "clsx";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { EmptySection } from "~/app/[locale]/boards/_types"; import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { GridStack } from "./gridstack/gridstack"; import { GridStack } from "./gridstack/gridstack";
import { useSectionItems } from "./use-section-items"; import { useSectionItems } from "./use-section-items";

View File

@@ -2,10 +2,12 @@ import type { RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react"; import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack"; import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types"; import type { Section } from "~/app/[locale]/boards/_types";
import { useEditMode, useMarkSectionAsReady, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; import { useMarkSectionAsReady } from "~/app/[locale]/boards/(content)/_ready-context";
import { useItemActions } from "../../items/item-actions"; import { useItemActions } from "../../items/item-actions";
import { useSectionActions } from "../section-actions"; import { useSectionActions } from "../section-actions";
import { initializeGridstack } from "./init-gridstack"; import { initializeGridstack } from "./init-gridstack";

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client"; import { useUpdateBoard } from "@homarr/boards/updater";
interface MoveAndResizeInnerSection { interface MoveAndResizeInnerSection {
innerSectionId: string; innerSectionId: string;

View File

@@ -1,5 +1,6 @@
import { useRequiredBoard } from "@homarr/boards/context";
import type { Section } from "~/app/[locale]/boards/_types"; import type { Section } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
export const useSectionItems = (section: Section) => { export const useSectionItems = (section: Section) => {
const board = useRequiredBoard(); const board = useRequiredBoard();

View File

@@ -1,7 +1,7 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import type { AppShellProps } from "@mantine/core"; import type { AppShellProps } from "@mantine/core";
import { useOptionalBoard } from "~/app/[locale]/boards/(content)/_context"; import { useOptionalBoard } from "@homarr/boards/context";
const supportedVideoFormats = ["mp4", "webm", "ogg"]; const supportedVideoFormats = ["mp4", "webm", "ogg"];
const isVideo = (url: string) => supportedVideoFormats.some((format) => url.toLowerCase().endsWith(`.${format}`)); const isVideo = (url: string) => supportedVideoFormats.some((format) => url.toLowerCase().endsWith(`.${format}`));

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; import { useRequiredBoard } from "@homarr/boards/context";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo"; import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import type { LogoWithTitleProps } from "./logo"; import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo"; import { Logo, LogoWithTitle } from "./logo";

View File

@@ -484,6 +484,9 @@ export const boardRouter = createTRPCRouter({
// layout settings // layout settings
columnCount: input.columnCount, columnCount: input.columnCount,
// Behavior settings
disableStatus: input.disableStatus,
}) })
.where(eq(boards.id, input.id)); .where(eq(boards.id, input.id));
}), }),

View File

@@ -10,6 +10,7 @@ import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding"; import { mediaTranscodingRouter } from "./media-transcoding";
import { minecraftRouter } from "./minecraft"; import { minecraftRouter } from "./minecraft";
import { notebookRouter } from "./notebook"; import { notebookRouter } from "./notebook";
import { optionsRouter } from "./options";
import { rssFeedRouter } from "./rssFeed"; import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home"; import { smartHomeRouter } from "./smart-home";
import { weatherRouter } from "./weather"; import { weatherRouter } from "./weather";
@@ -29,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter, healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter, mediaTranscoding: mediaTranscodingRouter,
minecraft: minecraftRouter, minecraft: minecraftRouter,
options: optionsRouter,
}); });

View File

@@ -0,0 +1,19 @@
import { getServerSettingsAsync } from "@homarr/db/queries";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const optionsRouter = createTRPCRouter({
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
const serverSettings = await getServerSettingsAsync(ctx.db);
return {
server: {
board: {
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
},
};
}),
});

View File

@@ -0,0 +1,9 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [
{
ignores: [],
},
...baseConfig,
];

View File

@@ -0,0 +1,38 @@
{
"name": "@homarr/boards",
"version": "0.1.0",
"private": true,
"license": "MIT",
"type": "module",
"exports": {
"./context": "./src/context.tsx",
"./updater": "./src/updater.ts",
"./edit-mode": "./src/edit-mode.tsx"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.19.0",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,70 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useEffect } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"];
} | null>(null);
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
const pathname = usePathname();
const utils = clientApi.useUtils();
// Invalidate the board when the pathname changes
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
useEffect(() => {
return () => {
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
};
}, [pathname, utils, initialBoard.name]);
return (
<BoardContext.Provider
value={{
board: data,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useRequiredBoard = () => {
const optionalBoard = useOptionalBoard();
if (!optionalBoard) {
throw new Error("Board is required");
}
return optionalBoard;
};
export const useOptionalBoard = () => {
const context = useContext(BoardContext);
return context?.board ?? null;
};

View File

@@ -0,0 +1,23 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
export const EditModeProvider = ({ children }: PropsWithChildren) => {
const editModeDisclosure = useDisclosure(false);
return <EditModeContext.Provider value={editModeDisclosure}>{children}</EditModeContext.Provider>;
};
export const useEditMode = () => {
const context = useContext(EditModeContext);
if (!context) {
throw new Error("EditMode is required");
}
return context;
};

View File

@@ -0,0 +1,34 @@
"use client";
import { useCallback } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
let boardName: string | null = null;
export const updateBoardName = (name: string | null) => {
boardName = name;
};
type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
const updateBoard = useCallback(
(updaterWithoutUndefined: UpdateCallback) => {
if (!boardName) {
throw new Error("Board name is not set");
}
utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
previous ? updaterWithoutUndefined(previous) : previous,
);
},
[utils],
);
return {
updateBoard,
};
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -1,4 +1,6 @@
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
import { sendPingRequestAsync } from "@homarr/ping"; import { sendPingRequestAsync } from "@homarr/ping";
import { pingChannel, pingUrlChannel } from "@homarr/redis"; import { pingChannel, pingUrlChannel } from "@homarr/redis";
@@ -13,6 +15,13 @@ const resetPreviousUrlsAsync = async () => {
export const pingJob = createCronJob("ping", EVERY_MINUTE, { export const pingJob = createCronJob("ping", EVERY_MINUTE, {
beforeStart: resetPreviousUrlsAsync, beforeStart: resetPreviousUrlsAsync,
}).withCallback(async () => { }).withCallback(async () => {
const boardSettings = await getServerSettingByKeyAsync(db, "board");
if (boardSettings.forceDisableStatus) {
logger.debug("Simple ping is disabled by server settings");
return;
}
const urls = await pingUrlChannel.getAllAsync(); const urls = await pingUrlChannel.getAllAsync();
await Promise.allSettled([...new Set(urls)].map(pingAsync)); await Promise.allSettled([...new Set(urls)].map(pingAsync));

View File

@@ -0,0 +1 @@
ALTER TABLE `board` ADD `disable_status` boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1738687012272, "when": 1738687012272,
"tag": "0023_fix_on_delete_actions", "tag": "0023_fix_on_delete_actions",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "5",
"when": 1738961147412,
"tag": "0024_mean_vin_gonzales",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1 @@
ALTER TABLE `board` ADD `disable_status` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -169,6 +169,13 @@
"when": 1738686324915, "when": 1738686324915,
"tag": "0023_fix_on_delete_actions", "tag": "0023_fix_on_delete_actions",
"breakpoints": true "breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1738961178990,
"tag": "0024_bitter_scrambler",
"breakpoints": true
} }
] ]
} }

View File

@@ -272,6 +272,7 @@ export const boards = mysqlTable("board", {
opacity: int().default(100).notNull(), opacity: int().default(100).notNull(),
customCss: text(), customCss: text(),
columnCount: int().default(10).notNull(), columnCount: int().default(10).notNull(),
disableStatus: boolean().default(false).notNull(),
}); });
export const boardUserPermissions = mysqlTable( export const boardUserPermissions = mysqlTable(

View File

@@ -258,6 +258,7 @@ export const boards = sqliteTable("board", {
opacity: int().default(100).notNull(), opacity: int().default(100).notNull(),
customCss: text(), customCss: text(),
columnCount: int().default(10).notNull(), columnCount: int().default(10).notNull(),
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
}); });
export const boardUserPermissions = sqliteTable( export const boardUserPermissions = sqliteTable(

View File

@@ -5,7 +5,7 @@ import { hashObjectBase64, Stopwatch } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server"; import { decryptSecret } from "@homarr/common/server";
import type { MaybeArray } from "@homarr/common/types"; import type { MaybeArray } from "@homarr/common/types";
import { db } from "@homarr/db"; import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import { getItemsWithIntegrationsAsync, getServerSettingsAsync } from "@homarr/db/queries";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { logger } from "@homarr/log"; import { logger } from "@homarr/log";
@@ -33,6 +33,7 @@ export const createRequestIntegrationJobHandler = <
}, },
) => { ) => {
return async () => { return async () => {
const serverSettings = await getServerSettingsAsync(db);
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: widgetKinds, kinds: widgetKinds,
}); });
@@ -52,7 +53,17 @@ export const createRequestIntegrationJobHandler = <
const oneOrMultipleInputs = getInput[itemForIntegration.kind]( const oneOrMultipleInputs = getInput[itemForIntegration.kind](
reduceWidgetOptionsWithDefaultValues( reduceWidgetOptionsWithDefaultValues(
itemForIntegration.kind, itemForIntegration.kind,
SuperJSON.parse(itemForIntegration.options), {
defaultSearchEngineId: serverSettings.search.defaultSearchEngineId,
openSearchInNewTab: true,
firstDayOfWeek: 1,
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: true,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
SuperJSON.parse<Record<string, unknown>>(itemForIntegration.options),
) as never, ) as never,
); );
for (const { integration } of itemForIntegration.integrations) { for (const { integration } of itemForIntegration.integrations) {

View File

@@ -28,6 +28,8 @@ export const defaultServerSettings = {
board: { board: {
homeBoardId: null as string | null, homeBoardId: null as string | null,
mobileHomeBoardId: null as string | null, mobileHomeBoardId: null as string | null,
enableStatusByDefault: true,
forceDisableStatus: false,
}, },
appearance: { appearance: {
defaultColorScheme: "light" as ColorScheme, defaultColorScheme: "light" as ColorScheme,

View File

@@ -8,7 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
import type { User } from "@homarr/db/schema"; import type { User } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings"; import type { ServerSettings } from "@homarr/server-settings";
type SettingsContextProps = Pick< export type SettingsContextProps = Pick<
User, User,
| "firstDayOfWeek" | "firstDayOfWeek"
| "defaultSearchEngineId" | "defaultSearchEngineId"
@@ -16,11 +16,15 @@ type SettingsContextProps = Pick<
| "mobileHomeBoardId" | "mobileHomeBoardId"
| "openSearchInNewTab" | "openSearchInNewTab"
| "pingIconsEnabled" | "pingIconsEnabled"
>; > &
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
interface PublicServerSettings { interface PublicServerSettings {
search: Pick<ServerSettings["search"], "defaultSearchEngineId">; search: Pick<ServerSettings["search"], "defaultSearchEngineId">;
board: Pick<ServerSettings["board"], "homeBoardId" | "mobileHomeBoardId">; board: Pick<
ServerSettings["board"],
"homeBoardId" | "mobileHomeBoardId" | "enableStatusByDefault" | "forceDisableStatus"
>;
} }
const SettingsContext = createContext<SettingsContextProps | null>(null); const SettingsContext = createContext<SettingsContextProps | null>(null);
@@ -39,6 +43,8 @@ export const SettingsProvider = ({
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId, homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId, mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
pingIconsEnabled: user?.pingIconsEnabled ?? false, pingIconsEnabled: user?.pingIconsEnabled ?? false,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
}} }}
> >
{children} {children}

View File

@@ -1048,7 +1048,7 @@
"label": "Show description tooltip" "label": "Show description tooltip"
}, },
"pingEnabled": { "pingEnabled": {
"label": "Enable simple ping" "label": "Enable status check"
} }
}, },
"error": { "error": {
@@ -2052,6 +2052,10 @@
"description": "You can add custom classes to your board items in the advanced options of each item and use them in the custom CSS above." "description": "You can add custom classes to your board items in the advanced options of each item and use them in the custom CSS above."
} }
}, },
"disableStatus": {
"label": "Disable app status",
"description": "Disables the status check for all apps on this board"
},
"columnCount": { "columnCount": {
"label": "Column count" "label": "Column count"
}, },
@@ -2085,6 +2089,9 @@
"customCss": { "customCss": {
"title": "Custom css" "title": "Custom css"
}, },
"behavior": {
"title": "Behavior"
},
"access": { "access": {
"title": "Access control", "title": "Access control",
"permission": { "permission": {
@@ -2476,6 +2483,17 @@
"label": "Global home board", "label": "Global home board",
"mobileLabel": "Global mobile board", "mobileLabel": "Global mobile board",
"description": "Only public boards are available for selection" "description": "Only public boards are available for selection"
},
"status": {
"title": "App status",
"enableStatusByDefault": {
"label": "Enable status by default",
"description": "When adding an app item, the status will be enabled by default"
},
"forceDisableStatus": {
"label": "Force disable status",
"description": "Status for apps will be disabled for all users and can't be enabled"
}
} }
}, },
"search": { "search": {

View File

@@ -58,6 +58,7 @@ const savePartialSettingsSchema = z
opacity: z.number().min(0).max(100), opacity: z.number().min(0).max(100),
customCss: z.string().max(16384), customCss: z.string().max(16384),
columnCount: z.number().min(1).max(24), columnCount: z.number().min(1).max(24),
disableStatus: z.boolean(),
}) })
.partial(); .partial();

View File

@@ -28,6 +28,7 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
@@ -36,6 +37,7 @@
"@homarr/modals": "workspace:^0.1.0", "@homarr/modals": "workspace:^0.1.0",
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0", "@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",

View File

@@ -7,6 +7,8 @@ import { IconLoader } from "@tabler/icons-react";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -17,6 +19,8 @@ import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode }: WidgetComponentProps<"app">) { export default function AppWidget({ options, isEditMode }: WidgetComponentProps<"app">) {
const t = useI18n(); const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const [app] = clientApi.app.byId.useSuspenseQuery( const [app] = clientApi.app.byId.useSuspenseQuery(
{ {
id: options.appId, id: options.appId,
@@ -81,7 +85,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
<img src={app.iconUrl} alt={app.name} className={combineClasses(classes.appIcon, "app-icon")} /> <img src={app.iconUrl} alt={app.name} className={combineClasses(classes.appIcon, "app-icon")} />
</Flex> </Flex>
</Tooltip.Floating> </Tooltip.Floating>
{options.pingEnabled && app.href ? ( {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}> <Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}`} />}>
<PingIndicator href={app.href} /> <PingIndicator href={app.href} />
</Suspense> </Suspense>

View File

@@ -5,13 +5,24 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", { export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps, icon: IconApps,
options: optionsBuilder.from((factory) => ({ createOptions(settings) {
appId: factory.app(), return optionsBuilder.from(
openInNewTab: factory.switch({ defaultValue: true }), (factory) => ({
showTitle: factory.switch({ defaultValue: true }), appId: factory.app(),
showDescriptionTooltip: factory.switch({ defaultValue: false }), openInNewTab: factory.switch({ defaultValue: true }),
pingEnabled: factory.switch({ defaultValue: false }), showTitle: factory.switch({ defaultValue: true }),
})), showDescriptionTooltip: factory.switch({ defaultValue: false }),
pingEnabled: factory.switch({ defaultValue: settings.enableStatusByDefault }),
}),
{
pingEnabled: {
shouldHide() {
return settings.forceDisableStatus;
},
},
},
);
},
errors: { errors: {
NOT_FOUND: { NOT_FOUND: {
icon: IconDeviceDesktopX, icon: IconDeviceDesktopX,

View File

@@ -10,50 +10,52 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconClock, icon: IconClock,
options: optionsBuilder.from((factory) => ({ createOptions() {
title: factory.text(), return optionsBuilder.from((factory) => ({
layout: factory.select({ title: factory.text(),
options: (["grid", "row", "column"] as const).map((value) => ({ layout: factory.select({
value, options: (["grid", "row", "column"] as const).map((value) => ({
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`), value,
})), label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
defaultValue: "column", })),
}), defaultValue: "column",
hideIcon: factory.switch({ defaultValue: false }), }),
hideHostname: factory.switch({ defaultValue: false }), hideIcon: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }), hideHostname: factory.switch({ defaultValue: false }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({ openNewTab: factory.switch({ defaultValue: true }),
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => { items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
return ( ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap"> return (
<Group wrap="nowrap"> <Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Handle /> <Group wrap="nowrap">
<Handle />
<Group> <Group>
<Avatar src={item.iconUrl} alt={item.name} /> <Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}> <Stack gap={0}>
<Text>{item.name}</Text> <Text>{item.name}</Text>
</Stack> </Stack>
</Group>
</Group> </Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
</ActionIcon>
</Group> </Group>
);
},
AddButton: BookmarkAddButton,
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
<ActionIcon variant="transparent" color="red" onClick={removeItem}> return {
<IconX size={20} /> data,
</ActionIcon> error,
</Group> isLoading,
); };
}, },
AddButton: BookmarkAddButton, }),
uniqueIdentifier: (item) => item.id, }));
useData: (initialIds) => { },
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
error,
isLoading,
};
},
}),
})),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -9,23 +9,25 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", { export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar, icon: IconCalendar,
options: optionsBuilder.from((factory) => ({ createOptions() {
releaseType: factory.multiSelect({ return optionsBuilder.from((factory) => ({
defaultValue: ["inCinemas", "digitalRelease"], releaseType: factory.multiSelect({
options: radarrReleaseTypes.map((value) => ({ defaultValue: ["inCinemas", "digitalRelease"],
value, options: radarrReleaseTypes.map((value) => ({
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`), value,
})), label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
}), })),
filterPastMonths: factory.number({ }),
validate: z.number().min(2).max(9999), filterPastMonths: factory.number({
defaultValue: 2, validate: z.number().min(2).max(9999),
}), defaultValue: 2,
filterFutureMonths: factory.number({ }),
validate: z.number().min(2).max(9999), filterFutureMonths: factory.number({
defaultValue: 2, validate: z.number().min(2).max(9999),
}), defaultValue: 2,
})), }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("calendar"), supportedIntegrations: getIntegrationKindsByCategory("calendar"),
integrationsRequired: false, integrationsRequired: false,
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -6,65 +6,67 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", { export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock, icon: IconClock,
options: optionsBuilder.from( createOptions() {
(factory) => ({ return optionsBuilder.from(
customTitleToggle: factory.switch({ (factory) => ({
defaultValue: false, customTitleToggle: factory.switch({
withDescription: true, defaultValue: false,
withDescription: true,
}),
customTitle: factory.text({
defaultValue: "",
}),
is24HourFormat: factory.switch({
defaultValue: true,
withDescription: true,
}),
showSeconds: factory.switch({
defaultValue: false,
}),
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
}),
showDate: factory.switch({
defaultValue: true,
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
}), }),
customTitle: factory.text({ {
defaultValue: "", customTitle: {
}), shouldHide: (options) => !options.customTitleToggle,
is24HourFormat: factory.switch({ },
defaultValue: true, timezone: {
withDescription: true, shouldHide: (options) => !options.useCustomTimezone,
}), },
showSeconds: factory.switch({ dateFormat: {
defaultValue: false, shouldHide: (options) => !options.showDate,
}), },
useCustomTimezone: factory.switch({ defaultValue: false }),
timezone: factory.select({
options: Intl.supportedValuesOf("timeZone").map((value) => value),
defaultValue: "Europe/London",
searchable: true,
withDescription: true,
}),
showDate: factory.switch({
defaultValue: true,
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
customTimeFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
customDateFormat: factory.text({
defaultValue: "",
withDescription: true,
}),
}),
{
customTitle: {
shouldHide: (options) => !options.customTitleToggle,
}, },
timezone: { );
shouldHide: (options) => !options.useCustomTimezone, },
},
dateFormat: {
shouldHide: (options) => !options.showDate,
},
},
),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -2,11 +2,13 @@ import type { LoaderComponent } from "next/dynamic";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import type { SettingsContextProps } from "@homarr/settings";
import type { stringOrTranslation } from "@homarr/translation"; import type { stringOrTranslation } from "@homarr/translation";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import type { WidgetImports } from "."; import type { WidgetImports } from ".";
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options"; import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
const createWithDynamicImport = const createWithDynamicImport =
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) => <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
@@ -30,7 +32,7 @@ export interface WidgetDefinition {
icon: TablerIcon; icon: TablerIcon;
supportedIntegrations?: IntegrationKind[]; supportedIntegrations?: IntegrationKind[];
integrationsRequired?: boolean; integrationsRequired?: boolean;
options: WidgetOptionsRecord; createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
errors?: Partial< errors?: Partial<
Record< Record<
DefaultErrorData["code"], DefaultErrorData["code"],
@@ -44,7 +46,7 @@ export interface WidgetDefinition {
} }
export interface WidgetProps<TKind extends WidgetKind> { export interface WidgetProps<TKind extends WidgetKind> {
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>; options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
integrationIds: string[]; integrationIds: string[];
itemId: string | undefined; // undefined when in preview mode itemId: string | undefined; // undefined when in preview mode
} }
@@ -52,13 +54,19 @@ export interface WidgetProps<TKind extends WidgetKind> {
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & { export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
boardId: string | undefined; // undefined when in preview mode boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean; isEditMode: boolean;
setOptions: ({ setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
newOptions,
}: {
newOptions: Partial<inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>>;
}) => void;
width: number; width: number;
height: number; height: number;
}; };
export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["options"]; export type WidgetOptionsRecordOf<TKind extends WidgetKind> = WidgetImports[TKind]["definition"]["createOptions"];
/**
* The following type should only include values that can be available for user (including anonymous).
* Because they need to be provided to the client to for example set certain default values.
*/
export interface WidgetOptionsSettings {
server: {
board: Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
};
}

View File

@@ -9,11 +9,13 @@ export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconDeviceGamepad, icon: IconDeviceGamepad,
options: optionsBuilder.from((factory) => ({ createOptions() {
showToggleAllButtons: factory.switch({ return optionsBuilder.from((factory) => ({
defaultValue: true, showToggleAllButtons: factory.switch({
}), defaultValue: true,
})), }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
errors: { errors: {
INTERNAL_SERVER_ERROR: { INTERNAL_SERVER_ERROR: {

View File

@@ -9,18 +9,20 @@ export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
icon: IconAd, icon: IconAd,
options: optionsBuilder.from((factory) => ({ createOptions() {
usePiHoleColors: factory.switch({ return optionsBuilder.from((factory) => ({
defaultValue: true, usePiHoleColors: factory.switch({
}), defaultValue: true,
layout: factory.select({ }),
options: (["grid", "row", "column"] as const).map((value) => ({ layout: factory.select({
value, options: (["grid", "row", "column"] as const).map((value) => ({
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`), value,
})), label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
defaultValue: "grid", })),
}), defaultValue: "grid",
})), }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
errors: { errors: {
INTERNAL_SERVER_ERROR: { INTERNAL_SERVER_ERROR: {

View File

@@ -33,76 +33,78 @@ const columnsSort = columnsList.filter((column) =>
export const { definition, componentLoader } = createWidgetDefinition("downloads", { export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload, icon: IconDownload,
options: optionsBuilder.from( createOptions() {
(factory) => ({ return optionsBuilder.from(
columns: factory.multiSelect({ (factory) => ({
defaultValue: ["integration", "name", "progress", "time", "actions"], columns: factory.multiSelect({
options: columnsList.map((value) => ({ defaultValue: ["integration", "name", "progress", "time", "actions"],
value, options: columnsList.map((value) => ({
label: (t) => t(`widget.downloads.items.${value}.columnTitle`), value,
})), label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
searchable: true, })),
searchable: true,
}),
enableRowSorting: factory.switch({
defaultValue: false,
}),
defaultSort: factory.select({
defaultValue: "type",
options: columnsSort.map((value) => ({
value,
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
})),
}),
descendingDefaultSort: factory.switch({
defaultValue: false,
}),
showCompletedUsenet: factory.switch({
defaultValue: true,
}),
showCompletedTorrent: factory.switch({
defaultValue: true,
}),
activeTorrentThreshold: factory.number({
//in KiB/s
validate: z.number().min(0),
defaultValue: 0,
step: 1,
}),
categoryFilter: factory.multiText({
defaultValue: [] as string[],
validate: z.string(),
}),
filterIsWhitelist: factory.switch({
defaultValue: false,
}),
applyFilterToRatio: factory.switch({
defaultValue: true,
}),
}), }),
enableRowSorting: factory.switch({ {
defaultValue: false, defaultSort: {
}), shouldHide: (options) => !options.enableRowSorting,
defaultSort: factory.select({ },
defaultValue: "type", descendingDefaultSort: {
options: columnsSort.map((value) => ({ shouldHide: (options) => !options.enableRowSorting,
value, },
label: (t) => t(`widget.downloads.items.${value}.columnTitle`), showCompletedUsenet: {
})), shouldHide: (_, integrationKinds) =>
}), !getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
descendingDefaultSort: factory.switch({ },
defaultValue: false, showCompletedTorrent: {
}), shouldHide: (_, integrationKinds) =>
showCompletedUsenet: factory.switch({ !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
defaultValue: true, },
}), activeTorrentThreshold: {
showCompletedTorrent: factory.switch({ shouldHide: (_, integrationKinds) =>
defaultValue: true, !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
}), },
activeTorrentThreshold: factory.number({ applyFilterToRatio: {
//in KiB/s shouldHide: (_, integrationKinds) =>
validate: z.number().min(0), !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
defaultValue: 0, },
step: 1,
}),
categoryFilter: factory.multiText({
defaultValue: [] as string[],
validate: z.string(),
}),
filterIsWhitelist: factory.switch({
defaultValue: false,
}),
applyFilterToRatio: factory.switch({
defaultValue: true,
}),
}),
{
defaultSort: {
shouldHide: (options) => !options.enableRowSorting,
}, },
descendingDefaultSort: { );
shouldHide: (options) => !options.enableRowSorting, },
},
showCompletedUsenet: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),
},
showCompletedTorrent: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
activeTorrentThreshold: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
applyFilterToRatio: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)),
},
},
),
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"), supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -7,34 +7,36 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
options: optionsBuilder.from((factory) => ({ createOptions() {
fahrenheit: factory.switch({ return optionsBuilder.from((factory) => ({
defaultValue: false, fahrenheit: factory.switch({
}), defaultValue: false,
cpu: factory.switch({ }),
defaultValue: true, cpu: factory.switch({
}), defaultValue: true,
memory: factory.switch({ }),
defaultValue: true, memory: factory.switch({
}), defaultValue: true,
fileSystem: factory.switch({ }),
defaultValue: true, fileSystem: factory.switch({
}), defaultValue: true,
defaultTab: factory.select({ }),
defaultValue: "system", defaultTab: factory.select({
options: [ defaultValue: "system",
{ value: "system", label: "System" }, options: [
{ value: "cluster", label: "Cluster" }, { value: "system", label: "System" },
] as const, { value: "cluster", label: "Cluster" },
}), ] as const,
sectionIndicatorRequirement: factory.select({ }),
defaultValue: "all", sectionIndicatorRequirement: factory.select({
options: [ defaultValue: "all",
{ value: "all", label: "All active" }, options: [
{ value: "any", label: "Any active" }, { value: "all", label: "All active" },
] as const, { value: "any", label: "Any active" },
}), ] as const,
})), }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"), supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
errors: { errors: {
INTERNAL_SERVER_ERROR: { INTERNAL_SERVER_ERROR: {

View File

@@ -5,17 +5,19 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("iframe", { export const { definition, componentLoader } = createWidgetDefinition("iframe", {
icon: IconBrowser, icon: IconBrowser,
options: optionsBuilder.from((factory) => ({ createOptions() {
embedUrl: factory.text(), return optionsBuilder.from((factory) => ({
allowFullScreen: factory.switch(), embedUrl: factory.text(),
allowScrolling: factory.switch({ allowFullScreen: factory.switch(),
defaultValue: true, allowScrolling: factory.switch({
}), defaultValue: true,
allowTransparency: factory.switch(), }),
allowPayment: factory.switch(), allowTransparency: factory.switch(),
allowAutoPlay: factory.switch(), allowPayment: factory.switch(),
allowMicrophone: factory.switch(), allowAutoPlay: factory.switch(),
allowCamera: factory.switch(), allowMicrophone: factory.switch(),
allowGeolocation: factory.switch(), allowCamera: factory.switch(),
})), allowGeolocation: factory.switch(),
}));
},
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -5,6 +5,7 @@ import { Center, Loader as UiLoader } from "@mantine/core";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings";
import * as app from "./app"; import * as app from "./app";
import * as bookmarks from "./bookmarks"; import * as bookmarks from "./bookmarks";
@@ -31,7 +32,7 @@ import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as video from "./video"; import * as video from "./video";
import * as weather from "./weather"; import * as weather from "./weather";
export type { WidgetDefinition } from "./definition"; export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps }; export type { WidgetComponentProps };
export const widgetImports = { export const widgetImports = {
@@ -94,9 +95,13 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
? WidgetImports[TKind]["definition"]["supportedIntegrations"] ? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: never[])[number]; : never[])[number];
export const reduceWidgetOptionsWithDefaultValues = (kind: WidgetKind, currentValue: Record<string, unknown> = {}) => { export const reduceWidgetOptionsWithDefaultValues = (
kind: WidgetKind,
settings: SettingsContextProps,
currentValue: Record<string, unknown> = {},
) => {
const definition = widgetImports[kind].definition; const definition = widgetImports[kind].definition;
const options = definition.options as Record<string, WidgetOptionDefinition>; const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
return objectEntries(options).reduce( return objectEntries(options).reduce(
(prev, [key, value]) => ({ (prev, [key, value]) => ({
...prev, ...prev,

View File

@@ -7,11 +7,13 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch, icon: IconReportSearch,
options: optionsBuilder.from((factory) => ({ createOptions() {
openIndexerSiteInNewTab: factory.switch({ return optionsBuilder.from((factory) => ({
defaultValue: true, openIndexerSiteInNewTab: factory.switch({
}), defaultValue: true,
})), }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"), supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
errors: { errors: {
INTERNAL_SERVER_ERROR: { INTERNAL_SERVER_ERROR: {

View File

@@ -7,10 +7,12 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion, icon: IconZoomQuestion,
options: optionsBuilder.from((factory) => ({ createOptions() {
linksTargetNewTab: factory.switch({ return optionsBuilder.from((factory) => ({
defaultValue: true, linksTargetNewTab: factory.switch({
}), defaultValue: true,
})), }),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -6,6 +6,8 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar, icon: IconChartBar,
options: {}, createOptions() {
return {};
},
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -4,6 +4,8 @@ import { createWidgetDefinition } from "../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo, icon: IconVideo,
options: {}, createOptions() {
return {};
},
supportedIntegrations: ["jellyfin", "plex"], supportedIntegrations: ["jellyfin", "plex"],
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -6,16 +6,18 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform, icon: IconTransform,
options: optionsBuilder.from((factory) => ({ createOptions() {
defaultView: factory.select({ return optionsBuilder.from((factory) => ({
defaultValue: "statistics", defaultView: factory.select({
options: [ defaultValue: "statistics",
{ label: "Workers", value: "workers" }, options: [
{ label: "Queue", value: "queue" }, { label: "Workers", value: "workers" },
{ label: "Statistics", value: "statistics" }, { label: "Queue", value: "queue" },
], { label: "Statistics", value: "statistics" },
}), ],
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }), }),
})), queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
}));
},
supportedIntegrations: ["tdarr"], supportedIntegrations: ["tdarr"],
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -6,9 +6,11 @@ import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", { export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft, icon: IconBrandMinecraft,
options: optionsBuilder.from((factory) => ({ createOptions() {
title: factory.text({ defaultValue: "" }), return optionsBuilder.from((factory) => ({
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }), title: factory.text({ defaultValue: "" }),
isBedrockServer: factory.switch({ defaultValue: false }), domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
})), isBedrockServer: factory.switch({ defaultValue: false }),
}));
},
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -8,6 +8,7 @@ import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { zodResolver } from "@homarr/form"; import { zodResolver } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals"; import { createModal, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { zodErrorMap } from "@homarr/validation/form"; import { zodErrorMap } from "@homarr/validation/form";
@@ -32,6 +33,7 @@ interface ModalProps<TSort extends WidgetKind> {
onSuccessfulEdit: (value: WidgetEditModalState) => void; onSuccessfulEdit: (value: WidgetEditModalState) => void;
integrationData: IntegrationSelectOption[]; integrationData: IntegrationSelectOption[];
integrationSupport: boolean; integrationSupport: boolean;
settings: SettingsContextProps;
} }
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => { export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
@@ -40,13 +42,16 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
// Translate the error messages // Translate the error messages
z.setErrorMap(zodErrorMap(t)); z.setErrorMap(zodErrorMap(t));
const { definition } = widgetImports[innerProps.kind];
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const form = useForm({ const form = useForm({
mode: "controlled", mode: "controlled",
initialValues: innerProps.value, initialValues: innerProps.value,
validate: zodResolver( validate: zodResolver(
z.object({ z.object({
options: z.object( options: z.object(
objectEntries(widgetImports[innerProps.kind].definition.options).reduce( objectEntries(options).reduce(
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => { (acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
if (value.validate) { if (value.validate) {
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate; acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
@@ -68,8 +73,6 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
}); });
const { openModal } = useModalAction(WidgetAdvancedOptionsModal); const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
const { definition } = widgetImports[innerProps.kind];
return ( return (
<form <form
onSubmit={form.onSubmit((values) => { onSubmit={form.onSubmit((values) => {
@@ -89,7 +92,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
{...form.getInputProps("integrationIds")} {...form.getInputProps("integrationIds")}
/> />
)} )}
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => { {Object.entries(options).map(([key, value]) => {
const Input = getInputForType(value.type); const Input = getInputForType(value.type);
if ( if (

View File

@@ -6,22 +6,24 @@ import { defaultContent } from "./default-content";
export const { definition, componentLoader } = createWidgetDefinition("notebook", { export const { definition, componentLoader } = createWidgetDefinition("notebook", {
icon: IconNotes, icon: IconNotes,
options: optionsBuilder.from( createOptions() {
(factory) => ({ return optionsBuilder.from(
showToolbar: factory.switch({ (factory) => ({
defaultValue: true, showToolbar: factory.switch({
defaultValue: true,
}),
allowReadOnlyCheck: factory.switch({
defaultValue: true,
}),
content: factory.text({
defaultValue: defaultContent,
}),
}), }),
allowReadOnlyCheck: factory.switch({ {
defaultValue: true, content: {
}), shouldHide: () => true, // Hide the content option as it can be modified in the editor
content: factory.text({ },
defaultValue: defaultContent,
}),
}),
{
content: {
shouldHide: () => true, // Hide the content option as it can be modified in the editor
}, },
}, );
), },
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -149,6 +149,10 @@ export type WidgetOptionType = WidgetOptionDefinition["type"];
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>; export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> = TDefinition["defaultValue"]; type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> = TDefinition["defaultValue"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type inferOptionsFromCreator<TOptions extends (settings: any) => WidgetOptionsRecord> =
inferOptionsFromDefinition<ReturnType<TOptions>>;
export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = { export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>; [key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
}; };

View File

@@ -12,21 +12,23 @@ import { optionsBuilder } from "../options";
*/ */
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss, icon: IconRss,
options: optionsBuilder.from((factory) => ({ createOptions() {
feedUrls: factory.multiText({ return optionsBuilder.from((factory) => ({
defaultValue: [], feedUrls: factory.multiText({
validate: z.string().url(), defaultValue: [],
}), validate: z.string().url(),
enableRtl: factory.switch({ }),
defaultValue: false, enableRtl: factory.switch({
}), defaultValue: false,
textLinesClamp: factory.number({ }),
defaultValue: 5, textLinesClamp: factory.number({
validate: z.number().min(1).max(50), defaultValue: 5,
}), validate: z.number().min(1).max(50),
maximumAmountPosts: factory.number({ }),
defaultValue: 100, maximumAmountPosts: factory.number({
validate: z.number().min(1).max(9999), defaultValue: 100,
}), validate: z.number().min(1).max(9999),
})), }),
}));
},
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -7,15 +7,17 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", { export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
icon: IconBinaryTree, icon: IconBinaryTree,
options: optionsBuilder.from((factory) => ({ createOptions() {
entityId: factory.text({ return optionsBuilder.from((factory) => ({
defaultValue: "sun.sun", entityId: factory.text({
}), defaultValue: "sun.sun",
displayName: factory.text({ }),
defaultValue: "Sun", displayName: factory.text({
}), defaultValue: "Sun",
entityUnit: factory.text(), }),
clickable: factory.switch(), entityUnit: factory.text(),
})), clickable: factory.switch(),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -7,9 +7,11 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", { export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
icon: IconBinaryTree, icon: IconBinaryTree,
options: optionsBuilder.from((factory) => ({ createOptions() {
displayName: factory.text(), return optionsBuilder.from((factory) => ({
automationId: factory.text(), displayName: factory.text(),
})), automationId: factory.text(),
}));
},
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
import type { SettingsContextProps } from "@homarr/settings";
import { createLanguageMapping } from "@homarr/translation"; import { createLanguageMapping } from "@homarr/translation";
import { widgetImports } from ".."; import { widgetImports } from "..";
@@ -8,26 +9,25 @@ import { widgetImports } from "..";
describe("Widget properties with description should have matching translations", async () => { describe("Widget properties with description should have matching translations", async () => {
const enTranslation = await createLanguageMapping().en(); const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => { objectEntries(widgetImports).forEach(([key, value]) => {
Object.entries(value.definition.options).forEach( Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => { const optionValue = optionValue_ as { withDescription: boolean };
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => { it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option; const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) { if (!(optionKey in option)) {
throw new Error(`Option ${optionKey} not found in translation`); throw new Error(`Option ${optionKey} not found in translation`);
} }
const value = option[optionKey as keyof typeof option]; const value = option[optionKey as keyof typeof option];
expect("description" in value).toBe(optionValue.withDescription); expect("description" in value).toBe(optionValue.withDescription);
}); });
}, });
);
}); });
}); });
describe("Widget properties should have matching name translations", async () => { describe("Widget properties should have matching name translations", async () => {
const enTranslation = await createLanguageMapping().en(); const enTranslation = await createLanguageMapping().en();
objectEntries(widgetImports).forEach(([key, value]) => { objectEntries(widgetImports).forEach(([key, value]) => {
Object.keys(value.definition.options).forEach((optionKey) => { Object.keys(value.definition.createOptions({} as SettingsContextProps)).forEach((optionKey) => {
it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => { it(`should have matching translations for ${optionKey} option name of ${key} widget`, () => {
const option = enTranslation.default.widget[key].option; const option = enTranslation.default.widget[key].option;
if (!(optionKey in option)) { if (!(optionKey in option)) {

View File

@@ -5,16 +5,18 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("video", { export const { definition, componentLoader } = createWidgetDefinition("video", {
icon: IconDeviceCctv, icon: IconDeviceCctv,
options: optionsBuilder.from((factory) => ({ createOptions() {
feedUrl: factory.text({ return optionsBuilder.from((factory) => ({
defaultValue: "", feedUrl: factory.text({
}), defaultValue: "",
hasAutoPlay: factory.switch({ }),
withDescription: true, hasAutoPlay: factory.switch({
}), withDescription: true,
isMuted: factory.switch({ }),
defaultValue: true, isMuted: factory.switch({
}), defaultValue: true,
hasControls: factory.switch(), }),
})), hasControls: factory.switch(),
}));
},
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -7,47 +7,49 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", { export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud, icon: IconCloud,
options: optionsBuilder.from( createOptions() {
(factory) => ({ return optionsBuilder.from(
isFormatFahrenheit: factory.switch(), (factory) => ({
disableTemperatureDecimals: factory.switch(), isFormatFahrenheit: factory.switch(),
showCurrentWindSpeed: factory.switch({ withDescription: true }), disableTemperatureDecimals: factory.switch(),
location: factory.location({ showCurrentWindSpeed: factory.switch({ withDescription: true }),
defaultValue: { location: factory.location({
name: "Paris", defaultValue: {
latitude: 48.85341, name: "Paris",
longitude: 2.3488, latitude: 48.85341,
}, longitude: 2.3488,
},
}),
dateFormat: factory.select({
options: [
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") },
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") },
{ value: "MMM D", label: dayjs().format("MMM D") },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch(),
hasForecast: factory.switch(),
forecastDayCount: factory.slider({
defaultValue: 5,
validate: z.number().min(1).max(7),
step: 1,
withDescription: true,
}),
}), }),
dateFormat: factory.select({ {
options: [ forecastDayCount: {
{ value: "dddd, MMMM D", label: dayjs().format("dddd, MMMM D") }, shouldHide({ hasForecast }) {
{ value: "dddd, D MMMM", label: dayjs().format("dddd, D MMMM") }, return !hasForecast;
{ value: "MMM D", label: dayjs().format("MMM D") }, },
{ value: "D MMM", label: dayjs().format("D MMM") },
{ value: "DD/MM/YYYY", label: dayjs().format("DD/MM/YYYY") },
{ value: "MM/DD/YYYY", label: dayjs().format("MM/DD/YYYY") },
{ value: "DD/MM", label: dayjs().format("DD/MM") },
{ value: "MM/DD", label: dayjs().format("MM/DD") },
],
defaultValue: "dddd, MMMM D",
withDescription: true,
}),
showCity: factory.switch(),
hasForecast: factory.switch(),
forecastDayCount: factory.slider({
defaultValue: 5,
validate: z.number().min(1).max(7),
step: 1,
withDescription: true,
}),
}),
{
forecastDayCount: {
shouldHide({ hasForecast }) {
return !hasForecast;
}, },
}, },
}, );
), },
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));

View File

@@ -3,6 +3,6 @@
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
}, },
"include": ["*.ts", "src"], "include": ["*.ts", "src", "*.tsx"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

37
pnpm-lock.yaml generated
View File

@@ -91,6 +91,9 @@ importers:
'@homarr/auth': '@homarr/auth':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../packages/auth version: link:../../packages/auth
'@homarr/boards':
specifier: workspace:^0.1.0
version: link:../../packages/boards
'@homarr/certificates': '@homarr/certificates':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../../packages/certificates version: link:../../packages/certificates
@@ -695,6 +698,34 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.7.3 version: 5.7.3
packages/boards:
dependencies:
'@homarr/api':
specifier: workspace:^0.1.0
version: link:../api
react:
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint
'@homarr/prettier-config':
specifier: workspace:^0.1.0
version: link:../../tooling/prettier
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
eslint:
specifier: ^9.19.0
version: 9.19.0
typescript:
specifier: ^5.7.3
version: 5.7.3
packages/certificates: packages/certificates:
dependencies: dependencies:
'@homarr/common': '@homarr/common':
@@ -1880,6 +1911,9 @@ importers:
'@homarr/auth': '@homarr/auth':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../auth version: link:../auth
'@homarr/boards':
specifier: workspace:^0.1.0
version: link:../boards
'@homarr/common': '@homarr/common':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../common version: link:../common
@@ -1904,6 +1938,9 @@ importers:
'@homarr/redis': '@homarr/redis':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../redis version: link:../redis
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../server-settings
'@homarr/settings': '@homarr/settings':
specifier: workspace:^0.1.0 specifier: workspace:^0.1.0
version: link:../settings version: link:../settings

View File

@@ -24,7 +24,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.18.0", "eslint": "^9.19.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"prettier": "@homarr/prettier-config" "prettier": "@homarr/prettier-config"