263 lines
8.4 KiB
TypeScript
263 lines
8.4 KiB
TypeScript
import type { MutableRefObject } from "react";
|
|
import { useRef } from "react";
|
|
import { Badge, Card, Center, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
|
import { useElementSize } from "@mantine/hooks";
|
|
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
|
import combineClasses from "clsx";
|
|
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
|
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 { useI18n } from "@homarr/translation/client";
|
|
import type { TablerIcon } from "@homarr/ui";
|
|
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
|
import type { WidgetDisplayMode } from "@homarr/widgets";
|
|
import { WidgetError } from "@homarr/widgets/errors";
|
|
|
|
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
|
import classes from "../sections/item.module.css";
|
|
import { useItemActions } from "./item-actions";
|
|
import itemContentClasses from "./item-content.module.css";
|
|
import { BoardItemMenu } from "./item-menu";
|
|
import { WidgetContextMenu } from "./widget-context-menu";
|
|
|
|
interface BoardItemContentProps {
|
|
item: SectionItem;
|
|
displayMode?: WidgetDisplayMode;
|
|
disableContextMenu?: boolean;
|
|
isReadOnly?: boolean;
|
|
disableItemConfiguration?: boolean;
|
|
widgetStateRef?: MutableRefObject<Record<string, unknown> | null>;
|
|
onOpenDetails?: () => void;
|
|
}
|
|
|
|
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
|
if (kind === "iframe") return "hidden";
|
|
if (kind === "systemResources") return "visible";
|
|
return undefined;
|
|
};
|
|
|
|
export const BoardItemContent = ({
|
|
item,
|
|
displayMode = "default",
|
|
disableContextMenu = false,
|
|
isReadOnly = false,
|
|
disableItemConfiguration = false,
|
|
widgetStateRef: externalWidgetStateRef,
|
|
onOpenDetails,
|
|
}: BoardItemContentProps) => {
|
|
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
|
const board = useRequiredBoard();
|
|
const internalWidgetStateRef = useRef<Record<string, unknown> | null>(null);
|
|
const widgetStateRef = externalWidgetStateRef ?? internalWidgetStateRef;
|
|
const content = (
|
|
<Card
|
|
ref={ref}
|
|
className={combineClasses(
|
|
classes.itemCard,
|
|
`${item.kind}-wrapper`,
|
|
"grid-stack-item-content",
|
|
item.advancedOptions.customCssClasses.join(" "),
|
|
)}
|
|
radius={board.itemRadius}
|
|
styles={{
|
|
root: {
|
|
"--opacity": board.opacity / 100,
|
|
containerType: "size",
|
|
overflow: getOverflowFromKind(item.kind),
|
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
},
|
|
}}
|
|
p={0}
|
|
>
|
|
<InnerContent
|
|
item={item}
|
|
width={width}
|
|
height={height}
|
|
displayMode={displayMode}
|
|
isReadOnly={isReadOnly}
|
|
disableItemConfiguration={disableItemConfiguration}
|
|
widgetStateRef={widgetStateRef}
|
|
onOpenDetails={onOpenDetails}
|
|
/>
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{disableContextMenu ? (
|
|
content
|
|
) : (
|
|
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
|
{content}
|
|
</WidgetContextMenu>
|
|
)}
|
|
{item.advancedOptions.title?.trim() && (
|
|
<Badge
|
|
pos="absolute"
|
|
// It's 4 because of the mantine-react-table that has z-index 3
|
|
style={{ zIndex: 4, insetInlineStart: 16 }}
|
|
top={2}
|
|
size="xs"
|
|
radius={board.itemRadius}
|
|
styles={{
|
|
root: {
|
|
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
|
"--opacity": board.opacity / 100,
|
|
},
|
|
}}
|
|
className={itemContentClasses.badge}
|
|
c="var(--mantine-color-text)"
|
|
>
|
|
{item.advancedOptions.title}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface InnerContentProps {
|
|
item: SectionItem;
|
|
width: number;
|
|
height: number;
|
|
displayMode: WidgetDisplayMode;
|
|
isReadOnly: boolean;
|
|
disableItemConfiguration: boolean;
|
|
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
|
onOpenDetails?: () => void;
|
|
}
|
|
|
|
const InnerContent = ({
|
|
item,
|
|
displayMode,
|
|
isReadOnly,
|
|
disableItemConfiguration,
|
|
onOpenDetails,
|
|
...dimensions
|
|
}: InnerContentProps) => {
|
|
const settings = useSettings();
|
|
const board = useRequiredBoard();
|
|
const [isEditMode] = useEditMode();
|
|
const t = useI18n();
|
|
const Comp = loadWidgetDynamic(item.kind);
|
|
const { definition } = widgetImports[item.kind];
|
|
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
|
const newItem = { ...item, options };
|
|
const { updateItemOptions } = useItemActions();
|
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
|
updateItemOptions({ itemId: item.id, newOptions });
|
|
const widgetSupportsIntegrations =
|
|
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
|
const hasNativeMobileSummary =
|
|
"mobile" in definition &&
|
|
definition.mobile !== undefined &&
|
|
"supportsCompactSummary" in definition.mobile &&
|
|
definition.mobile.supportsCompactSummary === true;
|
|
const useGenericMobileSummary = displayMode === "mobileSummary" && !hasNativeMobileSummary;
|
|
const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
|
|
const showItemMenu = !isReadOnly && !disableItemConfiguration;
|
|
const queryClient = useQueryClient();
|
|
|
|
return (
|
|
<QueryErrorResetBoundary>
|
|
{({ reset }) => (
|
|
<ErrorBoundary
|
|
onReset={() => {
|
|
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
|
reset();
|
|
}}
|
|
fallbackRender={({ resetErrorBoundary, error }) => (
|
|
<>
|
|
{showItemMenu && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
|
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
|
</>
|
|
)}
|
|
>
|
|
<Throw
|
|
error={new NoIntegrationSelectedError()}
|
|
when={
|
|
widgetSupportsIntegrations &&
|
|
item.integrationIds.length === 0 &&
|
|
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
|
}
|
|
/>
|
|
{showItemMenu && <BoardItemMenu offset={4} item={newItem} />}
|
|
{useGenericMobileSummary ? (
|
|
<GenericMobileSummary
|
|
icon={definition.icon}
|
|
title={title}
|
|
widgetName={t(`widget.${item.kind}.name`)}
|
|
detailsLabel={t("board.mobile.details")}
|
|
onOpenDetails={onOpenDetails}
|
|
/>
|
|
) : (
|
|
<Comp
|
|
options={options as never}
|
|
integrationIds={item.integrationIds}
|
|
isEditMode={isEditMode}
|
|
boardId={board.id}
|
|
itemId={item.id}
|
|
isReadOnly={isReadOnly}
|
|
setOptions={(partialNewOptions) => {
|
|
if (isReadOnly) return;
|
|
updateOptions({
|
|
newOptions: {
|
|
...options,
|
|
...partialNewOptions.newOptions,
|
|
},
|
|
});
|
|
}}
|
|
displayMode={displayMode}
|
|
{...dimensions}
|
|
/>
|
|
)}
|
|
</ErrorBoundary>
|
|
)}
|
|
</QueryErrorResetBoundary>
|
|
);
|
|
};
|
|
|
|
const GenericMobileSummary = ({
|
|
icon: Icon,
|
|
title,
|
|
widgetName,
|
|
detailsLabel,
|
|
onOpenDetails,
|
|
}: {
|
|
icon: TablerIcon;
|
|
title: string;
|
|
widgetName: string;
|
|
detailsLabel: string;
|
|
onOpenDetails?: () => void;
|
|
}) => (
|
|
<UnstyledButton
|
|
h="100%"
|
|
w="100%"
|
|
p="sm"
|
|
onClick={onOpenDetails}
|
|
aria-label={`${detailsLabel}: ${title}`}
|
|
data-mobile-generic-summary
|
|
>
|
|
<Center h="100%">
|
|
<Stack align="center" gap={4} maw="100%">
|
|
<ThemeIcon variant="light" size={36} radius="xl">
|
|
<Icon size={20} aria-hidden />
|
|
</ThemeIcon>
|
|
<Text fw={600} size="sm" ta="center" lineClamp={1} w="100%">
|
|
{widgetName}
|
|
</Text>
|
|
<Text c="dimmed" size="xs" ta="center" lineClamp={1} w="100%">
|
|
{detailsLabel}
|
|
</Text>
|
|
</Stack>
|
|
</Center>
|
|
</UnstyledButton>
|
|
);
|
|
|
|
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
|
|
if (when) throw error;
|
|
return null;
|
|
};
|