diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 1a1324323..83bc199ee 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -67,7 +67,6 @@
"@types/react": "^18.2.76",
"@types/react-dom": "^18.2.25",
"@types/chroma-js": "2.4.4",
- "dotenv-cli": "^7.4.1",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index 72f71fad4..66186acfc 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -1,2 +1,8 @@
-export const widgetKinds = ["clock", "weather", "app", "video"] as const;
+export const widgetKinds = [
+ "clock",
+ "weather",
+ "app",
+ "iframe",
+ "video",
+] as const;
export type WidgetKind = (typeof widgetKinds)[number];
diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts
index ba09f37df..bd6963876 100644
--- a/packages/translation/src/lang/en.ts
+++ b/packages/translation/src/lang/en.ts
@@ -372,6 +372,45 @@ export default {
},
},
},
+ iframe: {
+ name: "iFrame",
+ description:
+ "Embed any content from the internet. Some websites may restrict access.",
+ option: {
+ embedUrl: {
+ label: "Embed URL",
+ },
+ allowFullScreen: {
+ label: "Allow full screen",
+ },
+ allowTransparency: {
+ label: "Allow transparency",
+ },
+ allowScrolling: {
+ label: "Allow scrolling",
+ },
+ allowPayment: {
+ label: "Allow payment",
+ },
+ allowAutoPlay: {
+ label: "Allow auto play",
+ },
+ allowMicrophone: {
+ label: "Allow microphone",
+ },
+ allowCamera: {
+ label: "Allow camera",
+ },
+ allowGeolocation: {
+ label: "Allow geolocation",
+ },
+ },
+ error: {
+ noUrl: "No iFrame URL provided",
+ noBrowerSupport:
+ "Your Browser does not support iframes. Please update your browser.",
+ },
+ },
weather: {
name: "Weather",
description:
diff --git a/packages/widgets/src/iframe/component.module.css b/packages/widgets/src/iframe/component.module.css
new file mode 100644
index 000000000..68bc009ab
--- /dev/null
+++ b/packages/widgets/src/iframe/component.module.css
@@ -0,0 +1,8 @@
+.iframe {
+ border-radius: var(--mantine-radius-sm);
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: none;
+ background-color: transparent;
+}
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
new file mode 100644
index 000000000..df8a54244
--- /dev/null
+++ b/packages/widgets/src/iframe/component.tsx
@@ -0,0 +1,62 @@
+import { objectEntries } from "@homarr/common";
+import { useI18n } from "@homarr/translation/client";
+import { Box, IconBrowserOff, Stack, Text, Title } from "@homarr/ui";
+
+import type { WidgetComponentProps } from "../definition";
+import classes from "./component.module.css";
+
+export default function IFrameWidget({
+ options,
+}: WidgetComponentProps<"iframe">) {
+ const t = useI18n();
+ const { embedUrl, ...permissions } = options;
+ const allowedPermissions = getAllowedPermissions(permissions);
+
+ if (embedUrl.trim() === "") return ;
+
+ return (
+
+
+
+ );
+}
+
+const NoUrl = () => {
+ const t = useI18n();
+
+ return (
+
+
+ {t("widget.iframe.error.noUrl")}
+
+ );
+};
+
+const getAllowedPermissions = (
+ permissions: Omit["options"], "embedUrl">,
+) => {
+ return objectEntries(permissions)
+ .filter(([_key, value]) => value)
+ .map(([key]) => permissionMapping[key]);
+};
+
+const permissionMapping = {
+ allowAutoPlay: "autoplay",
+ allowCamera: "camera",
+ allowFullScreen: "fullscreen",
+ allowGeolocation: "geolocation",
+ allowMicrophone: "microphone",
+ allowPayment: "payment",
+ allowScrolling: "scrolling",
+ allowTransparency: "transparency",
+} satisfies Record<
+ keyof Omit["options"], "embedUrl">,
+ string
+>;
diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts
new file mode 100644
index 000000000..2af7c5587
--- /dev/null
+++ b/packages/widgets/src/iframe/index.ts
@@ -0,0 +1,24 @@
+import { IconBrowser } from "@homarr/ui";
+
+import { createWidgetDefinition } from "../definition";
+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(),
+ })),
+ },
+).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 6fece044d..2ccde0c45 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -8,6 +8,7 @@ import { Loader as UiLoader } from "@homarr/ui";
import * as app from "./app";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
+import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as video from "./video";
import * as weather from "./weather";
@@ -22,6 +23,7 @@ export const widgetImports = {
clock,
weather,
app,
+ iframe,
video,
} satisfies WidgetImportRecord;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5a303233d..86d4b5467 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -207,9 +207,6 @@ importers:
concurrently:
specifier: ^8.2.2
version: 8.2.2
- dotenv-cli:
- specifier: ^7.4.1
- version: 7.4.1
eslint:
specifier: ^8.57.0
version: 8.57.0