feat: add Uptime Kuma, Audiobookshelf, Paperless-ngx, and Navidrome integrations

Add four new integrations with full widget support:

- Uptime Kuma: dashboard monitors, uptime percentage ring, status counts
- Audiobookshelf: library stats, audiobooks/podcasts counts, listening time
- Paperless-ngx: document stats, inbox ratio ring, correspondents/tags
- Navidrome: artist/album/song counts, now playing with track list

Also adds a unified "Audio Stats" widget that merges Navidrome and
Audiobookshelf into a single configurable widget with backend-specific
option visibility.

Includes Vitest specs for pagination, status mapping, and shared utils.
This commit is contained in:
Thomas Camlong committed 2026-06-09 18:09:22 +02:00
1 parent a447a9f615
commit c9f003dadd
53 files changed
+3370 -17

No files matched your search

@@ -8,6 +8,7 @@ import {
IconPasswordUser,
IconPlug,
IconServer,
IconTag,
IconUser,
} from "@tabler/icons-react";
@@ -28,4 +29,5 @@ export const integrationSecretIcons = {
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
slug: IconTag,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -2,6 +2,7 @@ import { getBoardLayouts } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
@@ -28,7 +29,7 @@ export const createItemCallback =
id: id ?? createId(),
kind,
options,
layouts: createItemLayouts(previous, firstSection),
layouts: createItemLayouts(previous, firstSection, kind),
integrationIds,
advancedOptions: {
title: null,
@@ -48,15 +49,20 @@ export const createItemCallback =
};
};
const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayout[] => {
const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
const emptyPosition = boardLayout
? getFirstEmptyPosition(elements, boardLayout.columnCount)
? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -64,8 +70,8 @@ const createItemLayouts = (board: Board, currentSection: EmptySection): ItemLayo
}
return {
width: 1,
height: 1,
width: itemSize.width,
height: itemSize.height,
...emptyPosition,
sectionId: currentSection.id,
layoutId,
@@ -3,7 +3,7 @@ import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "
import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import { deprecatedWidgetKinds, getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
@@ -26,6 +26,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const items = useMemo(
() =>
objectEntries(widgetImports)
.filter(([kind]) => !deprecatedWidgetKinds.has(kind))
.map(([kind, value]) => ({
kind,
supportedIntegrations:
@@ -57,6 +57,9 @@ export const categoryTranslationKeys: Record<string, string> = {
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
};
export interface IntegrationGridItem {
@@ -1,6 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
import { NavLink } from "@mantine/core";
@@ -15,8 +16,21 @@ const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
};
const NavLinkHref = (props: NavigationLinkHref) => {
const pathMatches = (pathname: string, href: string) => pathname === href || pathname.startsWith(`${href}/`);
const useClientPathname = () => {
const pathname = usePathname();
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return { pathname, isClient };
};
const NavLinkHref = (props: NavigationLinkHref) => {
const { pathname, isClient } = useClientPathname();
const tourId = props["data-onboarding-tour-id"];
const link = props.external ? (
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
@@ -26,15 +40,15 @@ const NavLinkHref = (props: NavigationLinkHref) => {
label={props.label}
leftSection={props.icon}
href={props.href}
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
active={isClient && pathMatches(pathname, props.href)}
/>
);
return <TourTarget id={tourId}>{link}</TourTarget>;
};
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
const pathname = usePathname();
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
const { pathname, isClient } = useClientPathname();
const isActive = isClient && props.items.some((item) => pathMatches(pathname, item.href));
const nav = (
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
{props.items.map((item) => (