[]>(
+ () => [
+ {
+ accessorKey: "sessionName",
+ header: "Name",
+ },
+ {
+ accessorKey: "user.username",
+ header: "User",
+ Cell: ({ row }) => (
+
+
+ {row.original.user.username}
+
+ ),
+ },
+ {
+ accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
+ header: "Currently playing",
+ Cell: ({ row }) => {
+ if (row.original.currentlyPlaying) {
+ return (
+
+ {row.original.currentlyPlaying.name}
+
+ );
+ }
+
+ return null;
+ },
+ },
+ ],
+ [],
+ );
+
+ clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription(
+ {
+ integrationIds,
+ },
+ {
+ enabled: !isEditMode,
+ onData(data) {
+ currentStreamsHandlers.applyWhere(
+ (pair) => pair.integrationId === data.integrationId,
+ (pair) => {
+ return {
+ ...pair,
+ sessions: data.data,
+ };
+ },
+ );
+ },
+ },
+ );
+
+ // Only render the flat list of sessions when the currentStreams change
+ // Otherwise it will always create a new array reference and cause the table to re-render
+ const flatSessions = useMemo(() => currentStreams.flatMap((pair) => pair.sessions), [currentStreams]);
+
+ const table = useMantineReactTable({
+ columns,
+ data: flatSessions,
+ enableRowSelection: false,
+ enableColumnOrdering: false,
+ enableFullScreenToggle: false,
+ enableGlobalFilter: false,
+ enableDensityToggle: false,
+ enableFilters: false,
+ enablePagination: true,
+ enableSorting: true,
+ enableHiding: false,
+ enableTopToolbar: false,
+ enableColumnActions: false,
+ enableStickyHeader: true,
+ initialState: {
+ density: "xs",
+ },
+ mantinePaperProps: {
+ display: "flex",
+ h: "100%",
+ withBorder: false,
+ style: {
+ flexDirection: "column",
+ },
+ },
+ mantineTableProps: {
+ style: {
+ tableLayout: "fixed",
+ },
+ },
+ mantineTableContainerProps: {
+ style: {
+ flexGrow: 5,
+ },
+ },
+ });
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts
new file mode 100644
index 000000000..f5efac742
--- /dev/null
+++ b/packages/widgets/src/media-server/index.ts
@@ -0,0 +1,11 @@
+import { IconVideo } from "@tabler/icons-react";
+
+import { createWidgetDefinition } from "../definition";
+
+export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaServer", {
+ icon: IconVideo,
+ options: {},
+ supportedIntegrations: ["jellyfin"],
+})
+ .withServerData(() => import("./serverData"))
+ .withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/media-server/serverData.ts b/packages/widgets/src/media-server/serverData.ts
new file mode 100644
index 000000000..952cfadb4
--- /dev/null
+++ b/packages/widgets/src/media-server/serverData.ts
@@ -0,0 +1,21 @@
+"use server";
+
+import { api } from "@homarr/api/server";
+
+import type { WidgetProps } from "../definition";
+
+export default async function getServerDataAsync({ integrationIds }: WidgetProps<"mediaServer">) {
+ if (integrationIds.length === 0) {
+ return {
+ initialData: [],
+ };
+ }
+
+ const currentStreams = await api.widget.mediaServer.getCurrentStreams({
+ integrationIds,
+ });
+
+ return {
+ initialData: currentStreams,
+ };
+}
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 10dc57a02..4330e2752 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -62,6 +62,7 @@ export const WidgetEditModal = createModal>(({ actions, i
{Object.entries(definition.options).map(([key, value]: [string, OptionsBuilderResult[string]]) => {
const Input = getInputForType(value.type);
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!Input || value.shouldHide?.(form.values.options as never)) {
return null;
}
diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx
index e7967d81f..7ca935bc6 100644
--- a/packages/widgets/src/notebook/notebook.tsx
+++ b/packages/widgets/src/notebook/notebook.tsx
@@ -303,7 +303,9 @@ export function Notebook({ options, isEditMode, boardId, itemId }: WidgetCompone
- {(editor?.isActive("taskList") || editor?.isActive("bulletList") || editor?.isActive("orderedList")) && (
+ {(Boolean(editor?.isActive("taskList")) ||
+ Boolean(editor?.isActive("bulletList")) ||
+ Boolean(editor?.isActive("orderedList"))) && (
<>
@@ -680,7 +682,7 @@ function ListIndentIncrease() {
}, [editor, itemType]);
editor?.on("selectionUpdate", ({ editor }) => {
- setItemType(editor?.isActive("taskItem") ? "taskItem" : "listItem");
+ setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
});
return (
@@ -704,7 +706,7 @@ function ListIndentDecrease() {
}, [editor, itemType]);
editor?.on("selectionUpdate", ({ editor }) => {
- setItemType(editor?.isActive("taskItem") ? "taskItem" : "listItem");
+ setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
});
return (
diff --git a/packages/widgets/src/server/runner.tsx b/packages/widgets/src/server/runner.tsx
index 63d588e86..53f960cfe 100644
--- a/packages/widgets/src/server/runner.tsx
+++ b/packages/widgets/src/server/runner.tsx
@@ -45,6 +45,7 @@ const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const data = await loader.default({
...item,
options: optionsWithDefault as never,
+ itemId: item.id,
});
return ;
};
diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx
new file mode 100644
index 000000000..c7e590b71
--- /dev/null
+++ b/packages/widgets/src/smart-home/entity-state/component.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import React, { useState } from "react";
+import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
+
+import { clientApi } from "@homarr/api/client";
+
+import type { WidgetComponentProps } from "../../definition";
+
+export default function SmartHomeEntityStateWidget({
+ options,
+ integrationIds,
+ isEditMode,
+}: WidgetComponentProps<"smartHome-entityState">) {
+ const [lastState, setLastState] = useState<{
+ entityId: string;
+ state: string;
+ }>();
+
+ const utils = clientApi.useUtils();
+
+ clientApi.widget.smartHome.subscribeEntityState.useSubscription(
+ {
+ entityId: options.entityId,
+ },
+ {
+ onData(data) {
+ setLastState(data);
+ },
+ },
+ );
+
+ const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
+ onSettled: () => {
+ void utils.widget.smartHome.invalidate();
+ },
+ });
+
+ const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
+
+ const handleClick = React.useCallback(() => {
+ if (isEditMode) {
+ return;
+ }
+
+ if (!options.clickable) {
+ return;
+ }
+
+ mutate({
+ entityId: options.entityId,
+ integrationId: integrationIds[0] ?? "",
+ });
+ }, []);
+
+ return (
+
+
+
+
+ {options.displayName}
+
+
+ {lastState?.state}
+ {attribute}
+
+
+
+
+ );
+}
diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts
new file mode 100644
index 000000000..300188e5f
--- /dev/null
+++ b/packages/widgets/src/smart-home/entity-state/index.ts
@@ -0,0 +1,19 @@
+import { IconBinaryTree } from "@tabler/icons-react";
+
+import { createWidgetDefinition } from "../../definition";
+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(),
+ })),
+ supportedIntegrations: ["homeAssistant"],
+}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx
new file mode 100644
index 000000000..f05417e60
--- /dev/null
+++ b/packages/widgets/src/smart-home/execute-automation/component.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { ActionIcon, Center, LoadingOverlay, Overlay, Stack, Text, UnstyledButton } from "@mantine/core";
+import { useDisclosure, useTimeout } from "@mantine/hooks";
+import { IconAutomation, IconCheck } from "@tabler/icons-react";
+
+import { clientApi } from "@homarr/api/client";
+
+import type { WidgetComponentProps } from "../../definition";
+
+export default function SmartHomeTriggerAutomationWidget({
+ options,
+ integrationIds,
+ isEditMode,
+}: WidgetComponentProps<"smartHome-executeAutomation">) {
+ const [isShowSuccess, { open: showSuccess, close: closeSuccess }] = useDisclosure();
+ const { start } = useTimeout(() => {
+ closeSuccess();
+ }, 1000);
+
+ const { mutateAsync, isPending } = clientApi.widget.smartHome.executeAutomation.useMutation({
+ onSuccess: () => {
+ showSuccess();
+ start();
+ },
+ });
+ const handleClick = React.useCallback(async () => {
+ if (isEditMode) {
+ return;
+ }
+ await mutateAsync({
+ automationId: options.automationId,
+ integrationId: integrationIds[0] ?? "",
+ });
+ }, [isEditMode]);
+ return (
+
+ {isShowSuccess && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ {options.displayName}
+
+
+
+ );
+}
diff --git a/packages/widgets/src/smart-home/execute-automation/index.ts b/packages/widgets/src/smart-home/execute-automation/index.ts
new file mode 100644
index 000000000..a1996e9fe
--- /dev/null
+++ b/packages/widgets/src/smart-home/execute-automation/index.ts
@@ -0,0 +1,13 @@
+import { IconBinaryTree } from "@tabler/icons-react";
+
+import { createWidgetDefinition } from "../../definition";
+import { optionsBuilder } from "../../options";
+
+export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
+ icon: IconBinaryTree,
+ options: optionsBuilder.from((factory) => ({
+ displayName: factory.text(),
+ automationId: factory.text(),
+ })),
+ supportedIntegrations: ["homeAssistant"],
+}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index cbe2ad5f4..d7ad12484 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -1,13 +1,15 @@
-import { Card, Flex, Group, Stack, Text, Title } from "@mantine/core";
+import { Box, Group, HoverCard, Space, Stack, Text } from "@mantine/core";
import { IconArrowDownRight, IconArrowUpRight, IconMapPin } from "@tabler/icons-react";
+import combineClasses from "clsx";
+import dayjs from "dayjs";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
-import { WeatherIcon } from "./icon";
+import { WeatherDescription, WeatherIcon } from "./icon";
-export default function WeatherWidget({ options, width }: WidgetComponentProps<"weather">) {
+export default function WeatherWidget({ options }: WidgetComponentProps<"weather">) {
const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery(
{
latitude: options.location.latitude,
@@ -21,113 +23,123 @@ export default function WeatherWidget({ options, width }: WidgetComponentProps<"
);
return (
-
-
-
+
+ {options.hasForecast ? (
+
+ ) : (
+
+ )}
);
}
-interface DailyWeatherProps extends Pick, "width" | "options"> {
- shouldHide: boolean;
+interface WeatherProps extends Pick, "options"> {
weather: RouterOutputs["widget"]["weather"]["atLocation"];
}
-const DailyWeather = ({ shouldHide, width, options, weather }: DailyWeatherProps) => {
- if (shouldHide) {
- return null;
- }
-
+const DailyWeather = ({ options, weather }: WeatherProps) => {
return (
<>
-
-
- {getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)}
-
-
- {width > 200 && (
-
-
- {getPreferredUnit(weather.daily.temperature_2m_max[0]!, options.isFormatFahrenheit)}
-
- {getPreferredUnit(weather.daily.temperature_2m_min[0]!, options.isFormatFahrenheit)}
-
- )}
-
+
+
+
+
+
+
+
+
+
+
+
+ {getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}
+
+
+
+
+ {getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}
+
+
+ {getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}
+
{options.showCity && (
-
-
- {options.location.name}
-
+ <>
+
+
+
+
+ {options.location.name}
+
+
+ >
)}
>
);
};
-interface WeeklyForecastProps extends Pick, "width" | "options"> {
- shouldHide: boolean;
- weather: RouterOutputs["widget"]["weather"]["atLocation"];
-}
-
-const WeeklyForecast = ({ shouldHide, width, options, weather }: WeeklyForecastProps) => {
- if (shouldHide) {
- return null;
- }
-
+const WeeklyForecast = ({ options, weather }: WeatherProps) => {
return (
<>
-
+
{options.showCity && (
-
-
-
+ <>
+
+
{options.location.name}
-
+
+ >
)}
-
- 20 ? "red" : "blue"}>
- {getPreferredUnit(weather.current_weather.temperature, options.isFormatFahrenheit)}
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}
+