Files
homarr/apps/nextjs/src/components/board/items/item-content.tsx
T

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;
};