feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { Badge, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconBinaryTree } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../../definition";
|
||||
|
||||
@@ -14,7 +16,10 @@ export default function SmartHomeEntityStateWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"smartHome-entityState">) {
|
||||
const t = useI18n();
|
||||
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const integrationId = integrationIds[0]!;
|
||||
@@ -23,21 +28,25 @@ export default function SmartHomeEntityStateWidget({
|
||||
entityId: options.entityId,
|
||||
integrationId,
|
||||
};
|
||||
const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input);
|
||||
const { data: entity } = clientApi.widget.smartHome.entityDetails.useQuery(input);
|
||||
const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
|
||||
onSettled: () => void utils.widget.smartHome.entityState.invalidate(input),
|
||||
const { mutate, isPending, error } = clientApi.widget.smartHome.switchEntity.useMutation({
|
||||
onSettled: () => void utils.widget.smartHome.entityDetails.invalidate(input),
|
||||
});
|
||||
|
||||
const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
|
||||
const apiUnit = entity?.attributes.unit_of_measurement;
|
||||
const unit = options.entityUnit || (typeof apiUnit === "string" ? apiUnit : "");
|
||||
const attribute = unit.length > 0 ? ` ${unit}` : "";
|
||||
const isActionable = options.clickable && canInteract && !isPending;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (isEditMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.clickable) {
|
||||
if (!isActionable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,7 +54,7 @@ export default function SmartHomeEntityStateWidget({
|
||||
entityId: options.entityId,
|
||||
integrationId,
|
||||
});
|
||||
}, [integrationId, isEditMode, mutate, options.clickable, options.entityId]);
|
||||
}, [integrationId, isActionable, isEditMode, mutate, options.entityId]);
|
||||
|
||||
useRegisterSpotlightContextActions(
|
||||
`smartHome-entityState-${options.entityId}`,
|
||||
@@ -62,35 +71,70 @@ export default function SmartHomeEntityStateWidget({
|
||||
},
|
||||
};
|
||||
},
|
||||
disabled: !options.clickable,
|
||||
disabled: !isActionable,
|
||||
},
|
||||
],
|
||||
[handleClick, options.clickable, options.displayName, options.entityId],
|
||||
[handleClick, isActionable, options.displayName, options.entityId],
|
||||
);
|
||||
const isTiny = width < 128;
|
||||
const isTiny = width < 128 || height < 96;
|
||||
const advancedAttributes = [
|
||||
{ key: "unit" as const, value: entity?.attributes.unit_of_measurement },
|
||||
{ key: "deviceClass" as const, value: entity?.attributes.device_class },
|
||||
{ key: "icon" as const, value: entity?.attributes.icon },
|
||||
].filter(({ value }) => typeof value === "string" && value.length > 0);
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
mod={{ "entity-state": entityState, "entity-id": options.entityId }}
|
||||
mod={{ "entity-state": entity?.state, "entity-id": options.entityId }}
|
||||
onClick={handleClick}
|
||||
disabled={!isActionable}
|
||||
aria-description={error?.message}
|
||||
w="100%"
|
||||
h="100%"
|
||||
styles={{
|
||||
root: {
|
||||
cursor: options.clickable && !isEditMode ? "pointer" : "initial",
|
||||
cursor: isActionable && !isEditMode ? "pointer" : "initial",
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Stack align="center" gap={isTiny ? 4 : "md"} p="xs" maw="100%">
|
||||
<Text ta="center" fw="bold" size={isTiny ? "sm" : "lg"}>
|
||||
{options.displayName}
|
||||
{displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
|
||||
? entity.attributes.friendly_name
|
||||
: options.displayName}
|
||||
</Text>
|
||||
<Text ta="center" size={isTiny ? "xs" : "lg"}>
|
||||
{entityState}
|
||||
{entity?.state ?? "—"}
|
||||
{attribute}
|
||||
</Text>
|
||||
{displayMode === "advanced" && entity && (
|
||||
<>
|
||||
<Group justify="center" gap={4}>
|
||||
{advancedAttributes.map(({ key, value }) => (
|
||||
<Badge key={key} size="xs" variant="light" tt="none">
|
||||
{t(`widget.smartHome-entityState.advanced.attribute.${key}`)}: {String(value)}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.smartHome-entityState.advanced.entityId", { id: entity.entity_id })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("widget.smartHome-entityState.advanced.lastUpdated", {
|
||||
date: new Date(entity.last_updated).toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Tooltip label={error.message} multiline>
|
||||
<Text size="xs" c="red" lineClamp={2}>
|
||||
{t("widget.smartHome-entityState.error.toggleFailed")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBinaryTree } from "@tabler/icons-react";
|
||||
import { IconBinaryTree, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
@@ -7,7 +7,9 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
|
||||
icon: IconBinaryTree,
|
||||
supportsAdvancedFocus: true,
|
||||
queryKey: [["widget", "smartHome"]],
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
entityId: factory.text({
|
||||
@@ -21,4 +23,10 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
message: (t) => t("widget.smartHome-entityState.error.loadFailed"),
|
||||
},
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Center, LoadingOverlay, Overlay, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import { Center, LoadingOverlay, Overlay, Stack, Text, ThemeIcon, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
||||
import { IconAutomation, IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -16,27 +17,37 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
width,
|
||||
height,
|
||||
displayMode,
|
||||
}: WidgetComponentProps<"smartHome-executeAutomation">) {
|
||||
const integrationId = integrationIds[0];
|
||||
const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
|
||||
const [lastExecutedAt, setLastExecutedAt] = React.useState<Date | null>(null);
|
||||
const [isShowSuccess, { open: showSuccess, close: closeSuccess }] = useDisclosure();
|
||||
const { start } = useTimeout(() => {
|
||||
closeSuccess();
|
||||
}, 1000);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.widget.smartHome.executeAutomation.useMutation({
|
||||
const { mutateAsync, isPending, error } = clientApi.widget.smartHome.executeAutomation.useMutation({
|
||||
onSuccess: () => {
|
||||
setLastExecutedAt(new Date());
|
||||
showSuccess();
|
||||
start();
|
||||
},
|
||||
});
|
||||
const handleClick = React.useCallback(async () => {
|
||||
if (isEditMode) {
|
||||
if (isEditMode || isPending || !integrationId || !canInteract) {
|
||||
return;
|
||||
}
|
||||
await mutateAsync({
|
||||
automationId: options.automationId,
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
});
|
||||
}, [integrationIds, isEditMode, mutateAsync, options.automationId]);
|
||||
try {
|
||||
await mutateAsync({
|
||||
automationId: options.automationId,
|
||||
integrationId,
|
||||
});
|
||||
} catch {
|
||||
// The mutation exposes the error below and remains retryable.
|
||||
}
|
||||
}, [canInteract, integrationId, isEditMode, isPending, mutateAsync, options.automationId]);
|
||||
|
||||
const t = useI18n();
|
||||
useRegisterSpotlightContextActions(
|
||||
@@ -55,17 +66,23 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
},
|
||||
};
|
||||
},
|
||||
disabled: !integrationId || !canInteract || isPending,
|
||||
},
|
||||
],
|
||||
[handleClick, options.automationId, options.displayName],
|
||||
[canInteract, handleClick, integrationId, isPending, options.automationId, options.displayName],
|
||||
);
|
||||
|
||||
const isTiny = width < 128;
|
||||
const isTiny = width < 128 || height < 96;
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={handleClick}
|
||||
style={{ cursor: !isEditMode ? "pointer" : "initial", pointerEvents: isEditMode ? "none" : undefined }}
|
||||
disabled={!integrationId || !canInteract || isPending}
|
||||
style={{
|
||||
cursor: !isEditMode && integrationId && canInteract ? "pointer" : "initial",
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
}}
|
||||
aria-description={error?.message}
|
||||
w="100%"
|
||||
h="100%"
|
||||
>
|
||||
@@ -79,12 +96,44 @@ export default function SmartHomeTriggerAutomationWidget({
|
||||
</Overlay>
|
||||
)}
|
||||
<LoadingOverlay visible={isPending} />
|
||||
{error && displayMode !== "advanced" && (
|
||||
<Overlay>
|
||||
<Center w="100%" h="100%" p="xs">
|
||||
<Tooltip label={error.message} multiline>
|
||||
<Text size="xs" c="red" ta="center" lineClamp={3}>
|
||||
{t("widget.smartHome-executeAutomation.error.executeFailed")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Center>
|
||||
</Overlay>
|
||||
)}
|
||||
<Center w="100%" h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<IconAutomation size={isTiny ? 16 : undefined} />
|
||||
<Text ta="center" fw="bold" fz={isTiny ? "xs" : undefined}>
|
||||
{options.displayName}
|
||||
</Text>
|
||||
{displayMode === "advanced" && (
|
||||
<>
|
||||
{error ? (
|
||||
<Tooltip label={error.message} multiline>
|
||||
<Text ta="center" size="xs" c="red">
|
||||
{t("widget.smartHome-executeAutomation.error.executeFailed")}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text ta="center" size="xs" c="dimmed">
|
||||
{lastExecutedAt
|
||||
? t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
|
||||
time: lastExecutedAt.toLocaleTimeString(),
|
||||
})
|
||||
: t("widget.smartHome-executeAutomation.advanced.automationId", {
|
||||
id: options.automationId,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
</UnstyledButton>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
|
||||
icon: IconBinaryTree,
|
||||
maxIntegrations: 1,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
displayName: factory.text(),
|
||||
|
||||
Reference in new issue
Block a user