feat(settings): add simple-ping settings (#2118)
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/certificates": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
|
||||
@@ -5,12 +5,13 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { BoardCategorySection } from "~/components/board/sections/category-section";
|
||||
import { BoardEmptySection } from "~/components/board/sections/empty-section";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { useIsBoardReady, useRequiredBoard } from "./_context";
|
||||
import { useIsBoardReady } from "./_ready-context";
|
||||
|
||||
let boardName: string | null = null;
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "./_context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
export const CustomCss = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
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 { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
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 { HeaderButton } from "~/components/layout/header/button";
|
||||
import { env } from "~/env";
|
||||
import { useEditMode, useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
@@ -119,7 +120,7 @@ const AddMenu = () => {
|
||||
};
|
||||
|
||||
const EditModeMenu = () => {
|
||||
const [isEditMode, setEditMode] = useEditMode();
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
const t = useScopedI18n("board.action.edit");
|
||||
@@ -131,7 +132,7 @@ const EditModeMenu = () => {
|
||||
});
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
void revalidatePathActionAsync(`/boards/${board.name}`);
|
||||
setEditMode(false);
|
||||
close();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
@@ -143,8 +144,8 @@ const EditModeMenu = () => {
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) return saveBoard(board);
|
||||
setEditMode(true);
|
||||
}, [board, isEditMode, saveBoard, setEditMode]);
|
||||
open();
|
||||
}, [board, isEditMode, saveBoard, open]);
|
||||
|
||||
useHotkeys([["mod+e", toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -4,10 +4,10 @@ import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorsTuple } from "@mantine/core";
|
||||
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import type { ColorScheme } from "@homarr/definitions";
|
||||
|
||||
import { useColorSchemeManager } from "../../_client-providers/mantine";
|
||||
import { useRequiredBoard } from "./_context";
|
||||
|
||||
export const BoardMantineProvider = ({
|
||||
children,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,11 +5,11 @@ import { useRouter } from "next/navigation";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
|
||||
import { useRequiredBoard } from "../../(content)/_context";
|
||||
import classes from "./danger.module.css";
|
||||
|
||||
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine
|
||||
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { Board } from "../../_types";
|
||||
import { useUpdateBoard } from "../../(content)/_client";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrush,
|
||||
IconClick,
|
||||
IconFileTypeCss,
|
||||
IconLayout,
|
||||
IconPhoto,
|
||||
@@ -23,6 +24,7 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
|
||||
import { BackgroundSettingsContent } from "./_background";
|
||||
import { BehaviorSettingsContent } from "./_behavior";
|
||||
import { BoardAccessSettings } from "./_board-access";
|
||||
import { ColorSettingsContent } from "./_colors";
|
||||
import { CustomCssSettingsContent } from "./_customCss";
|
||||
@@ -95,6 +97,9 @@ export default async function BoardSettingsPage(props: Props) {
|
||||
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
|
||||
<CustomCssSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
<AccordionItemFor value="behavior" icon={IconClick}>
|
||||
<BehaviorSettingsContent board={board} />
|
||||
</AccordionItemFor>
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<AccordionItemFor value="access" icon={IconUser}>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { IconLayoutBoard } from "@tabler/icons-react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { useRequiredBoard } from "./(content)/_context";
|
||||
|
||||
export const BoardOtherHeaderActions = () => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -3,6 +3,8 @@ import { notFound } from "next/navigation";
|
||||
import { AppShellMain } from "@mantine/core";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { BoardProvider } from "@homarr/boards/context";
|
||||
import { EditModeProvider } from "@homarr/boards/edit-mode";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
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 { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import { BoardProvider } from "./(content)/_context";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
import { CustomCss } from "./(content)/_custom-css";
|
||||
import { BoardReadyProvider } from "./(content)/_ready-context";
|
||||
import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||
@@ -42,17 +44,21 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
|
||||
return (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -94,6 +94,8 @@ export default async function Layout(props: {
|
||||
board: {
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { Group, Switch, Text } from "@mantine/core";
|
||||
import { IconLayoutDashboard } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -56,6 +56,18 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
|
||||
)}
|
||||
{...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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
@@ -29,6 +30,7 @@ interface WidgetPreviewPageContentProps {
|
||||
}
|
||||
|
||||
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
|
||||
const settings = useSettings();
|
||||
const t = useScopedI18n("widgetPreview");
|
||||
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
|
||||
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
|
||||
@@ -43,7 +45,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
integrationIds: string[];
|
||||
advancedOptions: BoardItemAdvancedOptions;
|
||||
}>({
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
|
||||
integrationIds: [],
|
||||
advancedOptions: {
|
||||
customCssClasses: [],
|
||||
@@ -63,8 +65,9 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
});
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]);
|
||||
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
|
||||
|
||||
const Comp = loadWidgetDynamic(kind);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import type { CreateItemInput } from "./actions/create-item";
|
||||
import { createItemCallback } from "./actions/create-item";
|
||||
import type { DuplicateItemInput } from "./actions/duplicate-item";
|
||||
|
||||
@@ -5,11 +5,13 @@ import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
|
||||
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 { WidgetError } from "@homarr/widgets/errors";
|
||||
|
||||
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 { useItemActions } from "./item-actions";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
@@ -53,11 +55,12 @@ interface InnerContentProps {
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(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 { updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
|
||||
@@ -3,13 +3,14 @@ import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import type { Item } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { ItemMoveModal } from "./item-move-modal";
|
||||
@@ -35,6 +36,7 @@ export const BoardItemMenu = ({
|
||||
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
|
||||
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const settings = useSettings();
|
||||
|
||||
// Reset error boundary on next render if item has been edited
|
||||
useEffect(() => {
|
||||
@@ -75,6 +77,7 @@ export const BoardItemMenu = ({
|
||||
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { CategoryMenu } from "./category/category-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
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 { moveCategoryCallback } from "./actions/move-category";
|
||||
import type { RemoveCategoryInput } from "./actions/remove-category";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from "react";
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/db/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
@@ -99,8 +100,9 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
);
|
||||
}, [category, openModal, renameCategory, t]);
|
||||
|
||||
const settings = useSettings();
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -119,7 +121,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, [category, t, openConfirmModal]);
|
||||
}, [category, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
addCategoryAbove,
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import type { MaybePromise } from "@homarr/common/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useCategoryMenuActions } from "./category-menu-actions";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import type { WidgetComponentProps } from "@homarr/widgets";
|
||||
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||
|
||||
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
|
||||
.filter((item) => item.kind === kind)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
options: reduceWidgetOptionsWithDefaultValues(kind, item.options) as WidgetComponentProps<TKind>["options"],
|
||||
options: reduceWidgetOptionsWithDefaultValues(
|
||||
kind,
|
||||
settings,
|
||||
item.options,
|
||||
) as WidgetComponentProps<TKind>["options"],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
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 { BoardDynamicSection } from "./dynamic-section";
|
||||
import { GridStackItem } from "./gridstack/gridstack-item";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Card } from "@mantine/core";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import classes from "./item.module.css";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
import { createId } from "@homarr/db/client";
|
||||
|
||||
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
|
||||
interface RemoveDynamicSection {
|
||||
id: string;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionIcon, Menu } from "@mantine/core";
|
||||
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { DynamicSection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useDynamicSectionActions } from "./dynamic-actions";
|
||||
|
||||
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { GridStack } from "./gridstack/gridstack";
|
||||
import { useSectionItems } from "./use-section-items";
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { RefObject } from "react";
|
||||
import { createRef, useCallback, useEffect, useRef } from "react";
|
||||
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 { 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 { useSectionActions } from "../section-actions";
|
||||
import { initializeGridstack } from "./init-gridstack";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
|
||||
import { useUpdateBoard } from "@homarr/boards/updater";
|
||||
|
||||
interface MoveAndResizeInnerSection {
|
||||
innerSectionId: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import type { Section } from "~/app/[locale]/boards/_types";
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
|
||||
export const useSectionItems = (section: Section) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
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 isVideo = (url: string) => supportedVideoFormats.some((format) => url.toLowerCase().endsWith(`.${format}`));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
|
||||
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
|
||||
import type { LogoWithTitleProps } from "./logo";
|
||||
import { Logo, LogoWithTitle } from "./logo";
|
||||
|
||||
@@ -484,6 +484,9 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
// layout settings
|
||||
columnCount: input.columnCount,
|
||||
|
||||
// Behavior settings
|
||||
disableStatus: input.disableStatus,
|
||||
})
|
||||
.where(eq(boards.id, input.id));
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { mediaServerRouter } from "./media-server";
|
||||
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||
import { minecraftRouter } from "./minecraft";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { optionsRouter } from "./options";
|
||||
import { rssFeedRouter } from "./rssFeed";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { weatherRouter } from "./weather";
|
||||
@@ -29,4 +30,5 @@ export const widgetRouter = createTRPCRouter({
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaTranscoding: mediaTranscodingRouter,
|
||||
minecraft: minecraftRouter,
|
||||
options: optionsRouter,
|
||||
});
|
||||
|
||||
19
packages/api/src/router/widgets/options.ts
Normal file
19
packages/api/src/router/widgets/options.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
9
packages/boards/eslint.config.js
Normal file
9
packages/boards/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
38
packages/boards/package.json
Normal file
38
packages/boards/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
70
packages/boards/src/context.tsx
Normal file
70
packages/boards/src/context.tsx
Normal 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;
|
||||
};
|
||||
23
packages/boards/src/edit-mode.tsx
Normal file
23
packages/boards/src/edit-mode.tsx
Normal 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;
|
||||
};
|
||||
34
packages/boards/src/updater.ts
Normal file
34
packages/boards/src/updater.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
8
packages/boards/tsconfig.json
Normal file
8
packages/boards/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@homarr/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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 { sendPingRequestAsync } from "@homarr/ping";
|
||||
import { pingChannel, pingUrlChannel } from "@homarr/redis";
|
||||
@@ -13,6 +15,13 @@ const resetPreviousUrlsAsync = async () => {
|
||||
export const pingJob = createCronJob("ping", EVERY_MINUTE, {
|
||||
beforeStart: resetPreviousUrlsAsync,
|
||||
}).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();
|
||||
|
||||
await Promise.allSettled([...new Set(urls)].map(pingAsync));
|
||||
|
||||
1
packages/db/migrations/mysql/0024_mean_vin_gonzales.sql
Normal file
1
packages/db/migrations/mysql/0024_mean_vin_gonzales.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `board` ADD `disable_status` boolean DEFAULT false NOT NULL;
|
||||
1772
packages/db/migrations/mysql/meta/0024_snapshot.json
Normal file
1772
packages/db/migrations/mysql/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
||||
"when": 1738687012272,
|
||||
"tag": "0023_fix_on_delete_actions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "5",
|
||||
"when": 1738961147412,
|
||||
"tag": "0024_mean_vin_gonzales",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
packages/db/migrations/sqlite/0024_bitter_scrambler.sql
Normal file
1
packages/db/migrations/sqlite/0024_bitter_scrambler.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `board` ADD `disable_status` integer DEFAULT false NOT NULL;
|
||||
1697
packages/db/migrations/sqlite/meta/0024_snapshot.json
Normal file
1697
packages/db/migrations/sqlite/meta/0024_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -169,6 +169,13 @@
|
||||
"when": 1738686324915,
|
||||
"tag": "0023_fix_on_delete_actions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "6",
|
||||
"when": 1738961178990,
|
||||
"tag": "0024_bitter_scrambler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ export const boards = mysqlTable("board", {
|
||||
opacity: int().default(100).notNull(),
|
||||
customCss: text(),
|
||||
columnCount: int().default(10).notNull(),
|
||||
disableStatus: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
export const boardUserPermissions = mysqlTable(
|
||||
|
||||
@@ -258,6 +258,7 @@ export const boards = sqliteTable("board", {
|
||||
opacity: int().default(100).notNull(),
|
||||
customCss: text(),
|
||||
columnCount: int().default(10).notNull(),
|
||||
disableStatus: int({ mode: "boolean" }).default(false).notNull(),
|
||||
});
|
||||
|
||||
export const boardUserPermissions = sqliteTable(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { hashObjectBase64, Stopwatch } from "@homarr/common";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import type { MaybeArray } from "@homarr/common/types";
|
||||
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 { logger } from "@homarr/log";
|
||||
|
||||
@@ -33,6 +33,7 @@ export const createRequestIntegrationJobHandler = <
|
||||
},
|
||||
) => {
|
||||
return async () => {
|
||||
const serverSettings = await getServerSettingsAsync(db);
|
||||
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
|
||||
kinds: widgetKinds,
|
||||
});
|
||||
@@ -52,7 +53,17 @@ export const createRequestIntegrationJobHandler = <
|
||||
const oneOrMultipleInputs = getInput[itemForIntegration.kind](
|
||||
reduceWidgetOptionsWithDefaultValues(
|
||||
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,
|
||||
);
|
||||
for (const { integration } of itemForIntegration.integrations) {
|
||||
|
||||
@@ -28,6 +28,8 @@ export const defaultServerSettings = {
|
||||
board: {
|
||||
homeBoardId: null as string | null,
|
||||
mobileHomeBoardId: null as string | null,
|
||||
enableStatusByDefault: true,
|
||||
forceDisableStatus: false,
|
||||
},
|
||||
appearance: {
|
||||
defaultColorScheme: "light" as ColorScheme,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import type { User } from "@homarr/db/schema";
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
|
||||
type SettingsContextProps = Pick<
|
||||
export type SettingsContextProps = Pick<
|
||||
User,
|
||||
| "firstDayOfWeek"
|
||||
| "defaultSearchEngineId"
|
||||
@@ -16,11 +16,15 @@ type SettingsContextProps = Pick<
|
||||
| "mobileHomeBoardId"
|
||||
| "openSearchInNewTab"
|
||||
| "pingIconsEnabled"
|
||||
>;
|
||||
> &
|
||||
Pick<ServerSettings["board"], "enableStatusByDefault" | "forceDisableStatus">;
|
||||
|
||||
interface PublicServerSettings {
|
||||
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);
|
||||
@@ -39,6 +43,8 @@ export const SettingsProvider = ({
|
||||
homeBoardId: user?.homeBoardId ?? serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: user?.mobileHomeBoardId ?? serverSettings.board.mobileHomeBoardId,
|
||||
pingIconsEnabled: user?.pingIconsEnabled ?? false,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1048,7 +1048,7 @@
|
||||
"label": "Show description tooltip"
|
||||
},
|
||||
"pingEnabled": {
|
||||
"label": "Enable simple ping"
|
||||
"label": "Enable status check"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"disableStatus": {
|
||||
"label": "Disable app status",
|
||||
"description": "Disables the status check for all apps on this board"
|
||||
},
|
||||
"columnCount": {
|
||||
"label": "Column count"
|
||||
},
|
||||
@@ -2085,6 +2089,9 @@
|
||||
"customCss": {
|
||||
"title": "Custom css"
|
||||
},
|
||||
"behavior": {
|
||||
"title": "Behavior"
|
||||
},
|
||||
"access": {
|
||||
"title": "Access control",
|
||||
"permission": {
|
||||
@@ -2476,6 +2483,17 @@
|
||||
"label": "Global home board",
|
||||
"mobileLabel": "Global mobile board",
|
||||
"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": {
|
||||
|
||||
@@ -58,6 +58,7 @@ const savePartialSettingsSchema = z
|
||||
opacity: z.number().min(0).max(100),
|
||||
customCss: z.string().max(16384),
|
||||
columnCount: z.number().min(1).max(24),
|
||||
disableStatus: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/settings": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { IconLoader } from "@tabler/icons-react";
|
||||
import combineClasses from "clsx";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -17,6 +19,8 @@ import { PingIndicator } from "./ping/ping-indicator";
|
||||
|
||||
export default function AppWidget({ options, isEditMode }: WidgetComponentProps<"app">) {
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [app] = clientApi.app.byId.useSuspenseQuery(
|
||||
{
|
||||
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")} />
|
||||
</Flex>
|
||||
</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")}…`} />}>
|
||||
<PingIndicator href={app.href} />
|
||||
</Suspense>
|
||||
|
||||
@@ -5,13 +5,24 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||
icon: IconApps,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
appId: factory.app(),
|
||||
openInNewTab: factory.switch({ defaultValue: true }),
|
||||
showTitle: factory.switch({ defaultValue: true }),
|
||||
showDescriptionTooltip: factory.switch({ defaultValue: false }),
|
||||
pingEnabled: factory.switch({ defaultValue: false }),
|
||||
})),
|
||||
createOptions(settings) {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
appId: factory.app(),
|
||||
openInNewTab: factory.switch({ defaultValue: true }),
|
||||
showTitle: factory.switch({ defaultValue: true }),
|
||||
showDescriptionTooltip: factory.switch({ defaultValue: false }),
|
||||
pingEnabled: factory.switch({ defaultValue: settings.enableStatusByDefault }),
|
||||
}),
|
||||
{
|
||||
pingEnabled: {
|
||||
shouldHide() {
|
||||
return settings.forceDisableStatus;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
errors: {
|
||||
NOT_FOUND: {
|
||||
icon: IconDeviceDesktopX,
|
||||
|
||||
@@ -10,50 +10,52 @@ import { BookmarkAddButton } from "./add-button";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconClock,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
layout: factory.select({
|
||||
options: (["grid", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "column",
|
||||
}),
|
||||
hideIcon: factory.switch({ defaultValue: false }),
|
||||
hideHostname: factory.switch({ defaultValue: false }),
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
|
||||
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Handle />
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
layout: factory.select({
|
||||
options: (["grid", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "column",
|
||||
}),
|
||||
hideIcon: factory.switch({ defaultValue: false }),
|
||||
hideHostname: factory.switch({ defaultValue: false }),
|
||||
openNewTab: factory.switch({ defaultValue: true }),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
|
||||
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Handle />
|
||||
|
||||
<Group>
|
||||
<Avatar src={item.iconUrl} alt={item.name} />
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
</Stack>
|
||||
<Group>
|
||||
<Avatar src={item.iconUrl} alt={item.name} />
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
||||
<IconX size={20} />
|
||||
</ActionIcon>
|
||||
</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}>
|
||||
<IconX size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
AddButton: BookmarkAddButton,
|
||||
uniqueIdentifier: (item) => item.id,
|
||||
useData: (initialIds) => {
|
||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
};
|
||||
},
|
||||
}),
|
||||
})),
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
};
|
||||
},
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -9,23 +9,25 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||
icon: IconCalendar,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
defaultValue: ["inCinemas", "digitalRelease"],
|
||||
options: radarrReleaseTypes.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
|
||||
})),
|
||||
}),
|
||||
filterPastMonths: factory.number({
|
||||
validate: z.number().min(2).max(9999),
|
||||
defaultValue: 2,
|
||||
}),
|
||||
filterFutureMonths: factory.number({
|
||||
validate: z.number().min(2).max(9999),
|
||||
defaultValue: 2,
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
releaseType: factory.multiSelect({
|
||||
defaultValue: ["inCinemas", "digitalRelease"],
|
||||
options: radarrReleaseTypes.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
|
||||
})),
|
||||
}),
|
||||
filterPastMonths: factory.number({
|
||||
validate: z.number().min(2).max(9999),
|
||||
defaultValue: 2,
|
||||
}),
|
||||
filterFutureMonths: factory.number({
|
||||
validate: z.number().min(2).max(9999),
|
||||
defaultValue: 2,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
|
||||
integrationsRequired: false,
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -6,65 +6,67 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("clock", {
|
||||
icon: IconClock,
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
customTitleToggle: factory.switch({
|
||||
defaultValue: false,
|
||||
withDescription: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
customTitleToggle: factory.switch({
|
||||
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: "",
|
||||
}),
|
||||
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: {
|
||||
shouldHide: (options) => !options.customTitleToggle,
|
||||
{
|
||||
customTitle: {
|
||||
shouldHide: (options) => !options.customTitleToggle,
|
||||
},
|
||||
timezone: {
|
||||
shouldHide: (options) => !options.useCustomTimezone,
|
||||
},
|
||||
dateFormat: {
|
||||
shouldHide: (options) => !options.showDate,
|
||||
},
|
||||
},
|
||||
timezone: {
|
||||
shouldHide: (options) => !options.useCustomTimezone,
|
||||
},
|
||||
dateFormat: {
|
||||
shouldHide: (options) => !options.showDate,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { LoaderComponent } from "next/dynamic";
|
||||
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
|
||||
|
||||
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 { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import type { WidgetImports } from ".";
|
||||
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||
import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options";
|
||||
|
||||
const createWithDynamicImport =
|
||||
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
|
||||
@@ -30,7 +32,7 @@ export interface WidgetDefinition {
|
||||
icon: TablerIcon;
|
||||
supportedIntegrations?: IntegrationKind[];
|
||||
integrationsRequired?: boolean;
|
||||
options: WidgetOptionsRecord;
|
||||
createOptions: (settings: SettingsContextProps) => WidgetOptionsRecord;
|
||||
errors?: Partial<
|
||||
Record<
|
||||
DefaultErrorData["code"],
|
||||
@@ -44,7 +46,7 @@ export interface WidgetDefinition {
|
||||
}
|
||||
|
||||
export interface WidgetProps<TKind extends WidgetKind> {
|
||||
options: inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>;
|
||||
options: inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>;
|
||||
integrationIds: string[];
|
||||
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> & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
setOptions: ({
|
||||
newOptions,
|
||||
}: {
|
||||
newOptions: Partial<inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>>;
|
||||
}) => void;
|
||||
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
|
||||
width: 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">;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ export const widgetKind = "dnsHoleControls";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
icon: IconDeviceGamepad,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
showToggleAllButtons: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showToggleAllButtons: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
|
||||
@@ -9,18 +9,20 @@ export const widgetKind = "dnsHoleSummary";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||
icon: IconAd,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
usePiHoleColors: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
layout: factory.select({
|
||||
options: (["grid", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "grid",
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
usePiHoleColors: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
layout: factory.select({
|
||||
options: (["grid", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.dnsHoleSummary.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "grid",
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("dnsHole"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
|
||||
@@ -33,76 +33,78 @@ const columnsSort = columnsList.filter((column) =>
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||
icon: IconDownload,
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: ["integration", "name", "progress", "time", "actions"],
|
||||
options: columnsList.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||
})),
|
||||
searchable: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: ["integration", "name", "progress", "time", "actions"],
|
||||
options: columnsList.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.downloads.items.${value}.columnTitle`),
|
||||
})),
|
||||
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: 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,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
defaultSort: {
|
||||
shouldHide: (options) => !options.enableRowSorting,
|
||||
{
|
||||
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)),
|
||||
},
|
||||
},
|
||||
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"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -7,34 +7,36 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||
icon: IconHeartRateMonitor,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
fahrenheit: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
cpu: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
memory: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
fileSystem: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
defaultTab: factory.select({
|
||||
defaultValue: "system",
|
||||
options: [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "cluster", label: "Cluster" },
|
||||
] as const,
|
||||
}),
|
||||
sectionIndicatorRequirement: factory.select({
|
||||
defaultValue: "all",
|
||||
options: [
|
||||
{ value: "all", label: "All active" },
|
||||
{ value: "any", label: "Any active" },
|
||||
] as const,
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
fahrenheit: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
cpu: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
memory: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
fileSystem: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
defaultTab: factory.select({
|
||||
defaultValue: "system",
|
||||
options: [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "cluster", label: "Cluster" },
|
||||
] as const,
|
||||
}),
|
||||
sectionIndicatorRequirement: factory.select({
|
||||
defaultValue: "all",
|
||||
options: [
|
||||
{ value: "all", label: "All active" },
|
||||
{ value: "any", label: "Any active" },
|
||||
] as const,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
|
||||
@@ -5,17 +5,19 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
|
||||
icon: IconBrowser,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
embedUrl: factory.text(),
|
||||
allowFullScreen: factory.switch(),
|
||||
allowScrolling: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
allowTransparency: factory.switch(),
|
||||
allowPayment: factory.switch(),
|
||||
allowAutoPlay: factory.switch(),
|
||||
allowMicrophone: factory.switch(),
|
||||
allowCamera: factory.switch(),
|
||||
allowGeolocation: factory.switch(),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
embedUrl: factory.text(),
|
||||
allowFullScreen: factory.switch(),
|
||||
allowScrolling: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
allowTransparency: factory.switch(),
|
||||
allowPayment: factory.switch(),
|
||||
allowAutoPlay: factory.switch(),
|
||||
allowMicrophone: factory.switch(),
|
||||
allowCamera: factory.switch(),
|
||||
allowGeolocation: factory.switch(),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Center, Loader as UiLoader } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
|
||||
import * as app from "./app";
|
||||
import * as bookmarks from "./bookmarks";
|
||||
@@ -31,7 +32,7 @@ import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
|
||||
export type { WidgetDefinition } from "./definition";
|
||||
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
|
||||
export const widgetImports = {
|
||||
@@ -94,9 +95,13 @@ export type inferSupportedIntegrationsStrict<TKind extends WidgetKind> = (Widget
|
||||
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
|
||||
: 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 options = definition.options as Record<string, WidgetOptionDefinition>;
|
||||
const options = definition.createOptions(settings) as Record<string, WidgetOptionDefinition>;
|
||||
return objectEntries(options).reduce(
|
||||
(prev, [key, value]) => ({
|
||||
...prev,
|
||||
|
||||
@@ -7,11 +7,13 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
|
||||
icon: IconReportSearch,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
openIndexerSiteInNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
openIndexerSiteInNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
|
||||
@@ -7,10 +7,12 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
|
||||
icon: IconZoomQuestion,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
linksTargetNewTab: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createWidgetDefinition } from "../../definition";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
|
||||
icon: IconChartBar,
|
||||
options: {},
|
||||
createOptions() {
|
||||
return {};
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createWidgetDefinition } from "../definition";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
|
||||
icon: IconVideo,
|
||||
options: {},
|
||||
createOptions() {
|
||||
return {};
|
||||
},
|
||||
supportedIntegrations: ["jellyfin", "plex"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -6,16 +6,18 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
defaultView: factory.select({
|
||||
defaultValue: "statistics",
|
||||
options: [
|
||||
{ label: "Workers", value: "workers" },
|
||||
{ label: "Queue", value: "queue" },
|
||||
{ label: "Statistics", value: "statistics" },
|
||||
],
|
||||
}),
|
||||
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
defaultView: factory.select({
|
||||
defaultValue: "statistics",
|
||||
options: [
|
||||
{ label: "Workers", value: "workers" },
|
||||
{ label: "Queue", value: "queue" },
|
||||
{ label: "Statistics", value: "statistics" },
|
||||
],
|
||||
}),
|
||||
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: ["tdarr"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -6,9 +6,11 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
|
||||
icon: IconBrandMinecraft,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
title: factory.text({ defaultValue: "" }),
|
||||
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
|
||||
isBedrockServer: factory.switch({ defaultValue: false }),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
title: factory.text({ defaultValue: "" }),
|
||||
domain: factory.text({ defaultValue: "hypixel.net", validate: z.string().nonempty() }),
|
||||
isBedrockServer: factory.switch({ defaultValue: false }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { zodResolver } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { zodErrorMap } from "@homarr/validation/form";
|
||||
|
||||
@@ -32,6 +33,7 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
onSuccessfulEdit: (value: WidgetEditModalState) => void;
|
||||
integrationData: IntegrationSelectOption[];
|
||||
integrationSupport: boolean;
|
||||
settings: SettingsContextProps;
|
||||
}
|
||||
|
||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
@@ -40,13 +42,16 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
|
||||
// Translate the error messages
|
||||
z.setErrorMap(zodErrorMap(t));
|
||||
const { definition } = widgetImports[innerProps.kind];
|
||||
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
|
||||
|
||||
const form = useForm({
|
||||
mode: "controlled",
|
||||
initialValues: innerProps.value,
|
||||
validate: zodResolver(
|
||||
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> }]) => {
|
||||
if (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 { definition } = widgetImports[innerProps.kind];
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
@@ -89,7 +92,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
|
||||
{Object.entries(options).map(([key, value]) => {
|
||||
const Input = getInputForType(value.type);
|
||||
|
||||
if (
|
||||
|
||||
@@ -6,22 +6,24 @@ import { defaultContent } from "./default-content";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("notebook", {
|
||||
icon: IconNotes,
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
showToolbar: factory.switch({
|
||||
defaultValue: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
showToolbar: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
allowReadOnlyCheck: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
content: factory.text({
|
||||
defaultValue: defaultContent,
|
||||
}),
|
||||
}),
|
||||
allowReadOnlyCheck: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
content: factory.text({
|
||||
defaultValue: defaultContent,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
content: {
|
||||
shouldHide: () => true, // Hide the content option as it can be modified in the editor
|
||||
{
|
||||
content: {
|
||||
shouldHide: () => true, // Hide the content option as it can be modified in the editor
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -149,6 +149,10 @@ export type WidgetOptionType = WidgetOptionDefinition["type"];
|
||||
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;
|
||||
|
||||
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> = {
|
||||
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
|
||||
};
|
||||
|
||||
@@ -12,21 +12,23 @@ import { optionsBuilder } from "../options";
|
||||
*/
|
||||
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
|
||||
icon: IconRss,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
feedUrls: factory.multiText({
|
||||
defaultValue: [],
|
||||
validate: z.string().url(),
|
||||
}),
|
||||
enableRtl: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
textLinesClamp: factory.number({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(50),
|
||||
}),
|
||||
maximumAmountPosts: factory.number({
|
||||
defaultValue: 100,
|
||||
validate: z.number().min(1).max(9999),
|
||||
}),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
feedUrls: factory.multiText({
|
||||
defaultValue: [],
|
||||
validate: z.string().url(),
|
||||
}),
|
||||
enableRtl: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
textLinesClamp: factory.number({
|
||||
defaultValue: 5,
|
||||
validate: z.number().min(1).max(50),
|
||||
}),
|
||||
maximumAmountPosts: factory.number({
|
||||
defaultValue: 100,
|
||||
validate: z.number().min(1).max(9999),
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -7,15 +7,17 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
|
||||
icon: IconBinaryTree,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
defaultValue: "sun.sun",
|
||||
}),
|
||||
displayName: factory.text({
|
||||
defaultValue: "Sun",
|
||||
}),
|
||||
entityUnit: factory.text(),
|
||||
clickable: factory.switch(),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
defaultValue: "sun.sun",
|
||||
}),
|
||||
displayName: factory.text({
|
||||
defaultValue: "Sun",
|
||||
}),
|
||||
entityUnit: factory.text(),
|
||||
clickable: factory.switch(),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -7,9 +7,11 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
|
||||
icon: IconBinaryTree,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
displayName: factory.text(),
|
||||
automationId: factory.text(),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
displayName: factory.text(),
|
||||
automationId: factory.text(),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { SettingsContextProps } from "@homarr/settings";
|
||||
import { createLanguageMapping } from "@homarr/translation";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
@@ -8,26 +9,25 @@ import { widgetImports } from "..";
|
||||
describe("Widget properties with description should have matching translations", async () => {
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
objectEntries(widgetImports).forEach(([key, value]) => {
|
||||
Object.entries(value.definition.options).forEach(
|
||||
([optionKey, optionValue]: [string, { withDescription?: boolean }]) => {
|
||||
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
|
||||
const option = enTranslation.default.widget[key].option;
|
||||
if (!(optionKey in option)) {
|
||||
throw new Error(`Option ${optionKey} not found in translation`);
|
||||
}
|
||||
const value = option[optionKey as keyof typeof option];
|
||||
Object.entries(value.definition.createOptions({} as SettingsContextProps)).forEach(([optionKey, optionValue_]) => {
|
||||
const optionValue = optionValue_ as { withDescription: boolean };
|
||||
it(`should have matching translations for ${optionKey} option description of ${key} widget`, () => {
|
||||
const option = enTranslation.default.widget[key].option;
|
||||
if (!(optionKey in option)) {
|
||||
throw new Error(`Option ${optionKey} not found in translation`);
|
||||
}
|
||||
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 () => {
|
||||
const enTranslation = await createLanguageMapping().en();
|
||||
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`, () => {
|
||||
const option = enTranslation.default.widget[key].option;
|
||||
if (!(optionKey in option)) {
|
||||
|
||||
@@ -5,16 +5,18 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("video", {
|
||||
icon: IconDeviceCctv,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
feedUrl: factory.text({
|
||||
defaultValue: "",
|
||||
}),
|
||||
hasAutoPlay: factory.switch({
|
||||
withDescription: true,
|
||||
}),
|
||||
isMuted: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
hasControls: factory.switch(),
|
||||
})),
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
feedUrl: factory.text({
|
||||
defaultValue: "",
|
||||
}),
|
||||
hasAutoPlay: factory.switch({
|
||||
withDescription: true,
|
||||
}),
|
||||
isMuted: factory.switch({
|
||||
defaultValue: true,
|
||||
}),
|
||||
hasControls: factory.switch(),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -7,47 +7,49 @@ import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("weather", {
|
||||
icon: IconCloud,
|
||||
options: optionsBuilder.from(
|
||||
(factory) => ({
|
||||
isFormatFahrenheit: factory.switch(),
|
||||
disableTemperatureDecimals: factory.switch(),
|
||||
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
||||
location: factory.location({
|
||||
defaultValue: {
|
||||
name: "Paris",
|
||||
latitude: 48.85341,
|
||||
longitude: 2.3488,
|
||||
},
|
||||
createOptions() {
|
||||
return optionsBuilder.from(
|
||||
(factory) => ({
|
||||
isFormatFahrenheit: factory.switch(),
|
||||
disableTemperatureDecimals: factory.switch(),
|
||||
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
||||
location: factory.location({
|
||||
defaultValue: {
|
||||
name: "Paris",
|
||||
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: [
|
||||
{ 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,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
forecastDayCount: {
|
||||
shouldHide({ hasForecast }) {
|
||||
return !hasForecast;
|
||||
{
|
||||
forecastDayCount: {
|
||||
shouldHide({ hasForecast }) {
|
||||
return !hasForecast;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"include": ["*.ts", "src", "*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -91,6 +91,9 @@ importers:
|
||||
'@homarr/auth':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/auth
|
||||
'@homarr/boards':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/boards
|
||||
'@homarr/certificates':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../packages/certificates
|
||||
@@ -695,6 +698,34 @@ importers:
|
||||
specifier: ^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:
|
||||
dependencies:
|
||||
'@homarr/common':
|
||||
@@ -1880,6 +1911,9 @@ importers:
|
||||
'@homarr/auth':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../auth
|
||||
'@homarr/boards':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../boards
|
||||
'@homarr/common':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../common
|
||||
@@ -1904,6 +1938,9 @@ importers:
|
||||
'@homarr/redis':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../redis
|
||||
'@homarr/server-settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../server-settings
|
||||
'@homarr/settings':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../settings
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint": "^9.19.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
|
||||
Reference in New Issue
Block a user