diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index e0502488b..c2c80ddb2 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -55,6 +55,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Komga](komga.md)
- [Kopia](kopia.md)
- [Lidarr](lidarr.md)
+- [Linkwarden](linkwarden.md)
- [Mastodon](mastodon.md)
- [Mealie](mealie.md)
- [Medusa](medusa.md)
diff --git a/docs/widgets/services/linkwarden.md b/docs/widgets/services/linkwarden.md
new file mode 100644
index 000000000..bef196a99
--- /dev/null
+++ b/docs/widgets/services/linkwarden.md
@@ -0,0 +1,15 @@
+---
+title: Linkwarden
+description: Linkwarden Widget Configuration
+---
+
+Learn more about [Linkwarden](https://linkwarden.app/).
+
+Allowed fields: `["links", "collections", "tags"]`.
+
+```yaml
+widget:
+ type: linkwarden
+ url: http://linkwarden.host.or.ip
+ key: myApiKeyHere # On your Linkwarden install, go to Settings > Access Tokens. Generate a token.
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 4d6524b31..d48cc35e0 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -80,6 +80,7 @@ nav:
- widgets/services/komga.md
- widgets/services/kopia.md
- widgets/services/lidarr.md
+ - widgets/services/linkwarden.md
- widgets/services/mastodon.md
- widgets/services/mealie.md
- widgets/services/medusa.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 28c548953..cd9b2c143 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -905,5 +905,10 @@
"cameras": "Cameras",
"uptime": "Uptime",
"version": "Version"
+ },
+ "linkwarden": {
+ "links": "Links",
+ "collections": "Collections",
+ "tags": "Tags"
}
}
diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js
index 425228023..870d5b155 100644
--- a/src/utils/proxy/handlers/credentialed.js
+++ b/src/utils/proxy/handlers/credentialed.js
@@ -35,9 +35,16 @@ export default async function credentialedProxyHandler(req, res, map) {
} else if (widget.type === "gotify") {
headers["X-gotify-Key"] = `${widget.key}`;
} else if (
- ["authentik", "cloudflared", "ghostfolio", "mealie", "tailscale", "tandoor", "pterodactyl"].includes(
- widget.type,
- )
+ [
+ "authentik",
+ "cloudflared",
+ "ghostfolio",
+ "linkwarden",
+ "mealie",
+ "tailscale",
+ "tandoor",
+ "pterodactyl",
+ ].includes(widget.type)
) {
headers.Authorization = `Bearer ${widget.key}`;
} else if (widget.type === "truenas") {
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 341f5211d..159202421 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -54,6 +54,7 @@ const components = {
komga: dynamic(() => import("./komga/component")),
kopia: dynamic(() => import("./kopia/component")),
lidarr: dynamic(() => import("./lidarr/component")),
+ linkwarden: dynamic(() => import("./linkwarden/component")),
mastodon: dynamic(() => import("./mastodon/component")),
mealie: dynamic(() => import("./mealie/component")),
medusa: dynamic(() => import("./medusa/component")),
diff --git a/src/widgets/linkwarden/component.jsx b/src/widgets/linkwarden/component.jsx
new file mode 100644
index 000000000..e74a90a88
--- /dev/null
+++ b/src/widgets/linkwarden/component.jsx
@@ -0,0 +1,55 @@
+import React, { useState, useEffect } from "react";
+
+import Container from "components/services/widget/container";
+import Block from "components/services/widget/block";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { widget } = service;
+
+ const [stats, setStats] = useState({
+ totalLinks: null,
+ collections: { total: null },
+ tags: { total: null },
+ });
+
+ const { data: collectionsStatsData, error: collectionsStatsError } = useWidgetAPI(widget, "collections");
+ const { data: tagsStatsData, error: tagsStatsError } = useWidgetAPI(widget, "tags");
+
+ useEffect(() => {
+ if (collectionsStatsData?.response && tagsStatsData?.response) {
+ setStats({
+ // eslint-disable-next-line no-underscore-dangle
+ totalLinks: collectionsStatsData.response.reduce((sum, collection) => sum + (collection._count?.links || 0), 0),
+ collections: {
+ total: collectionsStatsData.response.length,
+ },
+ tags: {
+ total: tagsStatsData.response.length,
+ },
+ });
+ }
+ }, [collectionsStatsData, tagsStatsData]);
+
+ if (collectionsStatsError || tagsStatsError) {
+ return ;
+ }
+
+ if (!tagsStatsData || !collectionsStatsData) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/linkwarden/widget.js b/src/widgets/linkwarden/widget.js
new file mode 100644
index 000000000..799296f2b
--- /dev/null
+++ b/src/widgets/linkwarden/widget.js
@@ -0,0 +1,17 @@
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/api/v1/{endpoint}",
+ proxyHandler: credentialedProxyHandler,
+
+ mappings: {
+ collections: {
+ endpoint: "collections",
+ },
+ tags: {
+ endpoint: "tags",
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index a72a4126d..684e2b7f7 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -46,6 +46,7 @@ import kavita from "./kavita/widget";
import komga from "./komga/widget";
import kopia from "./kopia/widget";
import lidarr from "./lidarr/widget";
+import linkwarden from "./linkwarden/widget";
import mastodon from "./mastodon/widget";
import mealie from "./mealie/widget";
import medusa from "./medusa/widget";
@@ -167,6 +168,7 @@ const widgets = {
komga,
kopia,
lidarr,
+ linkwarden,
mastodon,
mealie,
medusa,